From 7570b96bffee094003ef07e1d7e3a6dbf5af6e4a Mon Sep 17 00:00:00 2001 From: mikereiche Date: Tue, 30 Aug 2022 12:25:40 -0700 Subject: [PATCH 1/3] Fix up FLE support. Closes #763. --- .../core/convert/DecryptingReadingConverter.java | 10 ++++------ .../core/convert/EncryptingWritingConverter.java | 5 +++-- .../core/convert/MappingCouchbaseConverter.java | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java index 38edf49a2..e7745cd17 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java @@ -17,6 +17,7 @@ import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.HashSet; import java.util.Set; @@ -44,15 +45,11 @@ public DecryptingReadingConverter(CryptoManager cryptoManager) { this.cryptoManager = cryptoManager; } - public void setConversionService(ConversionService conversionService) { - this.conversionService = conversionService; - } - @Override public Set getConvertibleTypes() { Set convertiblePairs = new HashSet<>(); Class[] clazzes = new Class[] { String.class, Integer.class, Long.class, Float.class, Double.class, - BigInteger.class, BigDecimal.class, Boolean.class }; + BigInteger.class, BigDecimal.class, Boolean.class, Enum.class }; for (Class clazz : clazzes) { convertiblePairs.add(new ConvertiblePair(CouchbaseDocument.class, clazz)); } @@ -61,7 +58,8 @@ public Set getConvertibleTypes() { @Override public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { - return source == null? null : new String(cryptoManager.decrypt(((CouchbaseDocument) source).getContent())); + return source == null ? null + : new String(cryptoManager.decrypt(((CouchbaseDocument) source).getContent()), StandardCharsets.UTF_8); } @Override diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java index b7ed1d3e2..eeb7240e7 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java @@ -17,6 +17,7 @@ import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -48,7 +49,7 @@ public Set getConvertibleTypes() { Set convertiblePairs = new HashSet<>(); Class[] clazzes = new Class[] { String.class, Integer.class, Long.class, Float.class, Double.class, - BigInteger.class, BigDecimal.class, Boolean.class }; + BigInteger.class, BigDecimal.class, Boolean.class, Enum.class }; for (Class clazz : clazzes) { convertiblePairs.add(new ConvertiblePair(clazz, String.class)); } @@ -63,7 +64,7 @@ public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor t com.couchbase.client.java.encryption.annotation.Encrypted ann = sourceType .getAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); Map result = new HashMap<>(); - result.putAll(cryptoManager.encrypt(source.toString().getBytes(), ann.encrypter())); + result.putAll(cryptoManager.encrypt(source.toString().getBytes(StandardCharsets.UTF_8), ann.encrypter())); return new Encrypted(result); } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java index bfb0afc47..6e9e41a74 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java @@ -945,7 +945,7 @@ private R readValue(Object value, TypeInformation type, Object parent) { } else if (value instanceof CouchbaseList) { return (R) readCollection(type, (CouchbaseList) value, parent); } else { - return (R) getPotentiallyConvertedSimpleRead(value, type.getClass()); // type does not have annotations + return (R) getPotentiallyConvertedSimpleRead(value, type.getType()); // type does not have annotations } } From dea9b86689050d9b59c3423ee07545679358db30 Mon Sep 17 00:00:00 2001 From: mikereiche Date: Thu, 8 Sep 2022 11:58:46 -0700 Subject: [PATCH 2/3] FLE Implemenation with Property Value Converter. Closes #763. --- .../AbstractCouchbaseConfiguration.java | 47 ++- .../convert/AbstractCouchbaseConverter.java | 74 ++++- .../convert/CouchbaseConversionContext.java | 52 +++ .../core/convert/CouchbaseConverter.java | 7 + .../convert/CouchbaseCustomConversions.java | 297 +++++++++++++++--- .../CouchbaseDefaultConversionService.java | 81 +++++ ...ouchbasePropertyValueConverterFactory.java | 42 +++ .../core/convert/CryptoConverter.java | 118 +++++++ .../convert/DecryptingReadingConverter.java | 69 ---- .../convert/EncryptingWritingConverter.java | 75 ----- .../convert/MappingCouchbaseConverter.java | 142 ++++----- .../core/convert/OtherConverters.java | 13 - .../core/mapping/CouchbaseSimpleTypes.java | 45 ++- .../couchbase/domain/ComparableEntity.java | 3 +- .../data/couchbase/domain/UserEncrypted.java | 98 ++---- ...yFieldLevelEncryptionIntegrationTests.java | 52 ++- 16 files changed, 819 insertions(+), 396 deletions(-) create mode 100644 spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java create mode 100644 spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseDefaultConversionService.java create mode 100644 spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java create mode 100644 spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java delete mode 100644 spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java delete mode 100644 spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java index 6e380315c..7ab114662 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import org.springframework.beans.factory.config.BeanDefinition; @@ -31,15 +32,21 @@ import org.springframework.core.convert.converter.GenericConverter; import org.springframework.core.type.filter.AnnotationTypeFilter; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.PropertyValueConverterRegistrar; +import org.springframework.data.convert.SimplePropertyValueConversions; import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.SimpleCouchbaseClientFactory; import org.springframework.data.couchbase.core.CouchbaseTemplate; import org.springframework.data.couchbase.core.ReactiveCouchbaseTemplate; import org.springframework.data.couchbase.core.convert.CouchbaseCustomConversions; +import org.springframework.data.couchbase.core.convert.CouchbasePropertyValueConverterFactory; +import org.springframework.data.couchbase.core.convert.CryptoConverter; import org.springframework.data.couchbase.core.convert.MappingCouchbaseConverter; import org.springframework.data.couchbase.core.convert.OtherConverters; import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; import org.springframework.data.couchbase.core.convert.translation.TranslationService; +import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; import org.springframework.data.couchbase.core.mapping.CouchbaseMappingContext; import org.springframework.data.couchbase.core.mapping.Document; import org.springframework.data.couchbase.repository.config.ReactiveRepositoryOperationsMapping; @@ -274,6 +281,7 @@ public MappingCouchbaseConverter mappingCouchbaseConverter(CouchbaseMappingConte CouchbaseCustomConversions couchbaseCustomConversions) { MappingCouchbaseConverter converter = new MappingCouchbaseConverter(couchbaseMappingContext, typeKey()); converter.setCustomConversions(couchbaseCustomConversions); + couchbaseMappingContext.setSimpleTypeHolder(couchbaseCustomConversions.getSimpleTypeHolder()); return converter; } @@ -397,26 +405,39 @@ protected boolean autoIndexCreation() { /** * Register custom Converters in a {@link CustomConversions} object if required. These {@link CustomConversions} will * be registered with the {@link #mappingCouchbaseConverter(CouchbaseMappingContext, CouchbaseCustomConversions)} )} - * and {@link #couchbaseMappingContext(CustomConversions)}. Returns an empty {@link CustomConversions} instance by - * default. + * and {@link #couchbaseMappingContext(CustomConversions)}. * - * @param cryptoManagerOptional optional cryptoManager. Make varargs for backwards compatibility. * @return must not be {@literal null}. */ @Bean(name = BeanNames.COUCHBASE_CUSTOM_CONVERSIONS) - public CustomConversions customConversions(CryptoManager... cryptoManagerOptional) { - assert (cryptoManagerOptional == null || cryptoManagerOptional.length <= 1); - CryptoManager cryptoManager = cryptoManagerOptional != null && cryptoManagerOptional.length == 1 - ? cryptoManagerOptional[0] - : null; + public CustomConversions customConversions() { + return customConversions(cryptoManager()); + } + + /** + * Register custom Converters in a {@link CustomConversions} object if required. These {@link CustomConversions} will + * be registered with the {@link #mappingCouchbaseConverter(CouchbaseMappingContext, CouchbaseCustomConversions)} )} + * and {@link #couchbaseMappingContext(CustomConversions)}. + * + * @param cryptoManager + * @return must not be {@literal null}. + */ + public CustomConversions customConversions(CryptoManager cryptoManager) { List newConverters = new ArrayList(); // the cryptoConverters take an argument, so they cannot be created in the // static block of CouchbaseCustomConversions. And they must be set before the super() constructor - // in CouchbaseCustomerConversions - if (cryptoManager != null) { - newConverters.addAll(OtherConverters.getCryptoConverters(cryptoManager)); - } - return new CouchbaseCustomConversions(newConverters); + // in CouchbaseCustomConversions + CustomConversions customConversions = CouchbaseCustomConversions.create( configurationAdapter -> { + SimplePropertyValueConversions valueConversions = new SimplePropertyValueConversions(); + valueConversions.setConverterFactory(new CouchbasePropertyValueConverterFactory(cryptoManager)); + valueConversions.setValueConverterRegistry(new PropertyValueConverterRegistrar() + .registerConverter(CouchbaseDocument.class, "", new CryptoConverter(cryptoManager))// unnecessary? + .buildRegistry()); + configurationAdapter.setPropertyValueConversions(valueConversions); + configurationAdapter.registerConverters(newConverters); + }); + + return customConversions; } @Bean diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java index 91b817bc6..d3e9b2ffa 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java @@ -17,12 +17,14 @@ package org.springframework.data.couchbase.core.convert; import java.util.Collections; +import java.util.Map; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.GenericConversionService; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.data.mapping.model.EntityInstantiators; @@ -99,7 +101,7 @@ public void afterPropertiesSet() { /** * This convertForWriteIfNeeded takes a property and accessor so that the annotations can be accessed (ie. @Encrypted) - * + * * @param prop the property to be converted to the class that would actually be stored. * @param accessor the property accessor * @return @@ -110,6 +112,63 @@ public Object convertForWriteIfNeeded(CouchbasePersistentProperty prop, Converti if (value == null) { return null; } + if (conversions.hasValueConverter(prop)) { + CouchbaseDocument encrypted = (CouchbaseDocument) conversions.getPropertyValueConversions() + .getValueConverter(prop).write(value, new CouchbaseConversionContext(prop, (MappingCouchbaseConverter)this, accessor)); + return encrypted; + } + Class targetClass = Object.class; + + if (prop.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null) { + targetClass = Map.class; + } + boolean canConvert = this.conversionService.canConvert(new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(targetClass)); + if (canConvert) { + return this.conversionService.convert(value, new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(targetClass)); + } + + Object result = this.conversions.getCustomWriteTarget(prop.getType()) // + .map(it -> this.conversionService.convert(value, new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(it))) // + .orElseGet(() -> Enum.class.isAssignableFrom(value.getClass()) ? ((Enum) value).name() : value); + + return result; + + } + + /** + * This convertForWriteIfNeeded takes a property and accessor so that the annotations can be accessed (ie. @Encrypted) + * + * @param prop the property to be converted to the class that would actually be stored. + * @param accessor the property accessor + * @return + */ +//@Override + public Object convertForWriteIfNeeded2(CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor) { + Object value = accessor.getProperty(prop, prop.getType()); + if (value == null) { + return null; + } + /* + if (conversions.hasValueConverter(prop)) { + CouchbaseDocument encrypted = (CouchbaseDocument) conversions.getPropertyValueConversions() + .getValueConverter(prop).write(value, new CouchbaseConversionContext(prop, (MappingCouchbaseConverter)this, accessor)); + return encrypted; + } + */ + Class targetClass = Object.class; + + if (prop.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null) { + targetClass = Map.class; + } + boolean canConvert = this.conversionService.canConvert(new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(targetClass)); + if (canConvert) { + return this.conversionService.convert(value, new TypeDescriptor(prop.getField()), + TypeDescriptor.valueOf(targetClass)); + } Object result = this.conversions.getCustomWriteTarget(prop.getType()) // .map(it -> this.conversionService.convert(value, new TypeDescriptor(prop.getField()), @@ -123,7 +182,7 @@ public Object convertForWriteIfNeeded(CouchbasePersistentProperty prop, Converti /** * This convertForWriteIfNeed takes only the value to convert. It cannot access the annotations of the Field being * converted. - * + * * @param value the value to be converted to the class that would actually be stored. * @return */ @@ -145,13 +204,13 @@ public Object convertToCouchbaseType(Object value, TypeInformation typeInfor if (value == null) { return null; } - + return this.conversions.getCustomWriteTarget(value.getClass()) // .map(it -> (Object) this.conversionService.convert(value, it)) // .orElseGet(() -> Enum.class.isAssignableFrom(value.getClass()) ? ((Enum) value).name() : value); - + } - + @Override public Object convertToCouchbaseType(String source) { return source; @@ -162,4 +221,9 @@ public Object convertToCouchbaseType(String source) { public Class getWriteClassFor(Class clazz) { return this.conversions.getCustomWriteTarget(clazz).orElse(clazz); } + + @Override + public CustomConversions getConversions(){ + return conversions; + } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java new file mode 100644 index 000000000..71b380d07 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConversionContext.java @@ -0,0 +1,52 @@ +package org.springframework.data.couchbase.core.convert; + +import org.springframework.beans.PropertyAccessor; +import org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * {@link ValueConversionContext} that allows to delegate read/write to an underlying {@link CouchbaseConverter}. + * + * @author Christoph Strobl + * @since 3.4 + */ +public class CouchbaseConversionContext implements ValueConversionContext { + + private final CouchbasePersistentProperty persistentProperty; + private final MappingCouchbaseConverter couchbaseConverter; + private final ConvertingPropertyAccessor propertyAccessor; + + public CouchbaseConversionContext(CouchbasePersistentProperty persistentProperty, + MappingCouchbaseConverter couchbaseConverter, ConvertingPropertyAccessor accessor) { + + this.persistentProperty = persistentProperty; + this.couchbaseConverter = couchbaseConverter; + this.propertyAccessor = accessor; + } + + @Override + public CouchbasePersistentProperty getProperty() { + return persistentProperty; + } + + @Override + public T write(@Nullable Object value, TypeInformation target) { + return (T) ValueConversionContext.super.write(value, target); + } + + @Override + public T read(@Nullable Object value, TypeInformation target) { + return ValueConversionContext.super.read(value, target); + } + + public MappingCouchbaseConverter getConverter(){ + return couchbaseConverter; + } + + public ConvertingPropertyAccessor getAccessor(){ + return propertyAccessor; + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java index 968916980..30fb59093 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseConverter.java @@ -16,6 +16,7 @@ package org.springframework.data.couchbase.core.convert; +import org.springframework.data.convert.CustomConversions; import org.springframework.data.convert.EntityConverter; import org.springframework.data.convert.EntityReader; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; @@ -80,4 +81,10 @@ Object convertForWriteIfNeeded(final CouchbasePersistentProperty source, // Object convertToCouchbaseType(Object source, TypeInformation typeInformation); // // Object convertToCouchbaseType(String source); + + /** + * return the conversions + * @return conversions + */ + CustomConversions getConversions(); } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java index 5806a0e9c..f83bec599 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseCustomConversions.java @@ -16,11 +16,32 @@ package org.springframework.data.couchbase.core.convert; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; import java.util.Collections; +import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.ConverterFactory; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.data.convert.PropertyValueConversions; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.PropertyValueConverterRegistrar; +import org.springframework.data.convert.SimplePropertyValueConversions; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.util.Assert; import com.couchbase.client.core.encryption.CryptoManager; @@ -47,16 +68,6 @@ public class CouchbaseCustomConversions extends org.springframework.data.convert private CryptoManager cryptoManager; - /** - * Expose the CryptoManager used by a DecryptingReadingConverter or EncryptingWritingConverter, if any. There can only - * be one. MappingCouchbaseConverter needs it. - * - * @return cryptoManager - */ - public CryptoManager getCryptoManager() { - return cryptoManager; - } - static { List converters = new ArrayList<>(); @@ -70,37 +81,251 @@ public CryptoManager getCryptoManager() { } /** - * Create a new instance with a given list of converters. + * Create a new {@link CouchbaseCustomConversions} instance registering the given converters. * - * @param converters the list of custom converters. + * @param converters must not be {@literal null}. */ - public CouchbaseCustomConversions(final List converters) { - super(STORE_CONVERSIONS, converters); - for (Object c : converters) { - if (c instanceof DecryptingReadingConverter) { - CryptoManager foundCryptoManager = ((DecryptingReadingConverter) c).cryptoManager; - if (foundCryptoManager == null) { - throw new RuntimeException(("DecryptingReadingConverter must have a cryptoManager")); - } else { - if (cryptoManager != null && this.cryptoManager != cryptoManager) { - throw new RuntimeException( - "all DecryptingReadingConverters and EncryptingWringConverters must use " + " a single CryptoManager"); - } - } - cryptoManager = foundCryptoManager; + public CouchbaseCustomConversions(List converters) { + this(CouchbaseConverterConfigurationAdapter.from(converters)); + } + + /** + * Create a new {@link CouchbaseCustomConversions} given {@link CouchbaseConverterConfigurationAdapter}. + * + * @param conversionConfiguration must not be {@literal null}. + * @since 2.3 + */ + protected CouchbaseCustomConversions(CouchbaseConverterConfigurationAdapter conversionConfiguration) { + super(conversionConfiguration.createConverterConfiguration()); + } + + /** + * Functional style {@link org.springframework.data.convert.CustomConversions} creation giving users a convenient way + * of configuring store specific capabilities by providing deferred hooks to what will be configured when creating the + * {@link org.springframework.data.convert.CustomConversions#CustomConversions(ConverterConfiguration) instance}. + * + * @param configurer must not be {@literal null}. + * @since 2.3 + */ + public static CouchbaseCustomConversions create(Consumer configurer) { + + CouchbaseConverterConfigurationAdapter adapter = new CouchbaseConverterConfigurationAdapter(); + configurer.accept(adapter); + + return new CouchbaseCustomConversions(adapter); + } + + /** + * {@link CouchbaseConverterConfigurationAdapter} encapsulates creation of + * {@link org.springframework.data.convert.CustomConversions.ConverterConfiguration} with CouchbaseDB specifics. + * + * @author Christoph Strobl + * @since 2.3 + */ + public static class CouchbaseConverterConfigurationAdapter { + + /** + * List of {@literal java.time} types having different representation when rendered + */ + private static final Set> JAVA_DRIVER_TIME_SIMPLE_TYPES = new HashSet<>( + Arrays.asList(LocalDate.class, LocalTime.class, LocalDateTime.class)); + + private boolean useNativeDriverJavaTimeCodecs = false; + private final List customConverters = new ArrayList<>(); + private final PropertyValueConversions internalValueConversion = PropertyValueConversions.simple(it -> {}); + private PropertyValueConversions propertyValueConversions = internalValueConversion; + + /** + * Create a {@link CouchbaseConverterConfigurationAdapter} using the provided {@code converters} and our own codecs + * for JSR-310 types. + * + * @param converters must not be {@literal null}. + * @return + */ + public static CouchbaseConverterConfigurationAdapter from(List converters) { + + Assert.notNull(converters, "Converters must not be null"); + + CouchbaseConverterConfigurationAdapter converterConfigurationAdapter = new CouchbaseConverterConfigurationAdapter(); + converterConfigurationAdapter.registerConverters(converters); + return converterConfigurationAdapter; + } + + /** + * Add a custom {@link Converter} implementation. + * + * @param converter must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerConverter(Converter converter) { + + Assert.notNull(converter, "Converter must not be null!"); + customConverters.add(converter); + return this; + } + + /** + * Gateway to register property specific converters. + * + * @param configurationAdapter must not be {@literal null}. + * @return this. + * @since 3.4 + */ + public CouchbaseConverterConfigurationAdapter configurePropertyConversions( + Consumer> configurationAdapter) { + + Assert.state(valueConversions() instanceof SimplePropertyValueConversions, + "Configured PropertyValueConversions does not allow setting custom ConverterRegistry"); + + PropertyValueConverterRegistrar propertyValueConverterRegistrar = new PropertyValueConverterRegistrar(); + configurationAdapter.accept(propertyValueConverterRegistrar); + + ((SimplePropertyValueConversions) valueConversions()) + .setValueConverterRegistry(propertyValueConverterRegistrar.buildRegistry()); + return this; + } + + /** + * Add a custom {@link ConverterFactory} implementation. + * + * @param converterFactory must not be {@literal null}. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerConverterFactory(ConverterFactory converterFactory) { + + Assert.notNull(converterFactory, "ConverterFactory must not be null"); + customConverters.add(converterFactory); + return this; + } + + /** + * Add {@link Converter converters}, {@link ConverterFactory factories}, {@link ConverterBuilder.ConverterAware + * converter-aware objects}, and {@link GenericConverter generic converters}. + * + * @param converters must not be {@literal null} nor contain {@literal null} values. + * @return this. + */ + public CouchbaseConverterConfigurationAdapter registerConverters(Collection converters) { + + Assert.notNull(converters, "Converters must not be null"); + Assert.noNullElements(converters, "Converters must not be null nor contain null values"); + + customConverters.addAll(converters); + return this; + } + + /** + * Add a custom/default {@link PropertyValueConverterFactory} implementation used to serve + * {@link PropertyValueConverter}. + * + * @param converterFactory must not be {@literal null}. + * @return this. + * @since 3.4 + */ + public CouchbaseConverterConfigurationAdapter registerPropertyValueConverterFactory( + PropertyValueConverterFactory converterFactory) { + + Assert.state(valueConversions() instanceof SimplePropertyValueConversions, + "Configured PropertyValueConversions does not allow setting custom ConverterRegistry"); + + ((SimplePropertyValueConversions) valueConversions()).setConverterFactory(converterFactory); + return this; + } + + /** + * Optionally set the {@link PropertyValueConversions} to be applied during mapping. + *

+ * Use this method if {@link #configurePropertyConversions(Consumer)} and + * {@link #registerPropertyValueConverterFactory(PropertyValueConverterFactory)} are not sufficient. + * + * @param valueConversions must not be {@literal null}. + * @return this. + * @since 3.4 + */ + public CouchbaseConverterConfigurationAdapter setPropertyValueConversions( + PropertyValueConversions valueConversions) { + + Assert.notNull(valueConversions, "PropertyValueConversions must not be null"); + this.propertyValueConversions = valueConversions; + return this; + } + + PropertyValueConversions valueConversions() { + + if (this.propertyValueConversions == null) { + this.propertyValueConversions = internalValueConversion; + } + + return this.propertyValueConversions; + } + + ConverterConfiguration createConverterConfiguration() { + + if (hasDefaultPropertyValueConversions() + && propertyValueConversions instanceof SimplePropertyValueConversions svc) { + svc.init(); } - if (c instanceof EncryptingWritingConverter) { - CryptoManager foundCryptoManager = ((EncryptingWritingConverter) c).cryptoManager; - if (foundCryptoManager == null) { - throw new RuntimeException(("EncryptingWritingConverter must have a cryptoManager")); - } else { - if (cryptoManager != null && this.cryptoManager != cryptoManager) { - throw new RuntimeException( - "all DecryptingReadingConverters and EncryptingWringConverters must use " + " a single CryptoManager"); - } + + if (!useNativeDriverJavaTimeCodecs) { + return new ConverterConfiguration(STORE_CONVERSIONS, this.customConverters, convertiblePair -> true, + this.propertyValueConversions); + } + + /* + * We need to have those converters using UTC as the default ones would go on with the systemDefault. + */ + List converters = new ArrayList<>(STORE_CONVERTERS.size() + 3); + converters.add(DateToUtcLocalDateConverter.INSTANCE); + converters.add(DateToUtcLocalTimeConverter.INSTANCE); + converters.add(DateToUtcLocalDateTimeConverter.INSTANCE); + converters.addAll(STORE_CONVERTERS); + + StoreConversions storeConversions = StoreConversions.of(new SimpleTypeHolder(JAVA_DRIVER_TIME_SIMPLE_TYPES, + SimpleTypeHolder.DEFAULT /* CouchbaseSimpoleTypes.HOLDER */), converters); + + return new ConverterConfiguration(storeConversions, this.customConverters, convertiblePair -> { + + // Avoid default registrations + + if (JAVA_DRIVER_TIME_SIMPLE_TYPES.contains(convertiblePair.getSourceType()) + && Date.class.isAssignableFrom(convertiblePair.getTargetType())) { + return false; } - cryptoManager = foundCryptoManager; + + return true; + }, this.propertyValueConversions); + } + + private enum DateToUtcLocalDateTimeConverter implements Converter { + INSTANCE; + + @Override + public LocalDateTime convert(Date source) { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(source.getTime()), ZoneId.of("UTC")); } } + + private enum DateToUtcLocalTimeConverter implements Converter { + INSTANCE; + + @Override + public LocalTime convert(Date source) { + return DateToUtcLocalDateTimeConverter.INSTANCE.convert(source).toLocalTime(); + } + } + + private enum DateToUtcLocalDateConverter implements Converter { + INSTANCE; + + @Override + public LocalDate convert(Date source) { + return DateToUtcLocalDateTimeConverter.INSTANCE.convert(source).toLocalDate(); + } + } + + private boolean hasDefaultPropertyValueConversions() { + return propertyValueConversions == internalValueConversion; + } + } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseDefaultConversionService.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseDefaultConversionService.java new file mode 100644 index 000000000..9185d9025 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbaseDefaultConversionService.java @@ -0,0 +1,81 @@ +package org.springframework.data.couchbase.core.convert; + +import org.springframework.core.convert.ConversionFailedException; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.core.convert.support.GenericConversionService; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +public class CouchbaseDefaultConversionService extends GenericConversionService { + public CouchbaseDefaultConversionService() { + super(); + } + @Override + @Nullable + public Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) { + Assert.notNull(targetType, "Target type to convert to cannot be null"); + if (sourceType == null) { + Assert.isTrue(source == null, "Source must be [null] if source type == [null]"); + return handleResult(null, targetType, convertNullSource(null, targetType)); + } + if (source != null && !sourceType.getObjectType().isInstance(source)) { + throw new IllegalArgumentException("Source to convert from must be an instance of [" + + sourceType + "]; instead it was a [" + source.getClass().getName() + "]"); + } + GenericConverter converter = getConverter(sourceType, targetType); + if (converter != null) { + Object result = invokeConverter(converter, source, sourceType, targetType); + return handleResult(sourceType, targetType, result); + } + return handleConverterNotFound(source, sourceType, targetType); + } + + @Nullable + public static Object invokeConverter(GenericConverter converter, @Nullable Object source, + TypeDescriptor sourceType, TypeDescriptor targetType) { + + try { + return converter.convert(source, sourceType, targetType); + } + catch (ConversionFailedException ex) { + throw ex; + } + catch (Throwable ex) { + throw new ConversionFailedException(sourceType, targetType, source, ex); + } + } + + @Nullable + private Object handleConverterNotFound( + @Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) { + + if (source == null) { + assertNotPrimitiveTargetType(sourceType, targetType); + return null; + } + if ((sourceType == null || sourceType.isAssignableTo(targetType)) && + targetType.getObjectType().isInstance(source)) { + return source; + } + throw new ConverterNotFoundException(sourceType, targetType); + } + + @Nullable + private Object handleResult(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType, @Nullable Object result) { + if (result == null) { + assertNotPrimitiveTargetType(sourceType, targetType); + } + return result; + } + + private void assertNotPrimitiveTargetType(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType) { + if (targetType.isPrimitive()) { + throw new ConversionFailedException(sourceType, targetType, null, + new IllegalArgumentException("A null value cannot be assigned to a primitive type")); + } + } + + +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java new file mode 100644 index 000000000..319281267 --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CouchbasePropertyValueConverterFactory.java @@ -0,0 +1,42 @@ +package org.springframework.data.couchbase.core.convert; + +import com.couchbase.client.core.encryption.CryptoManager; +import com.querydsl.codegen.Property; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.PropertyValueConverterFactory; +import org.springframework.data.convert.ValueConversionContext; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.Map; + +public class CouchbasePropertyValueConverterFactory implements PropertyValueConverterFactory { + + CryptoManager cryptoManager; + Map>,PropertyValueConverter> converterCache = new HashMap<>(); + public CouchbasePropertyValueConverterFactory(CryptoManager cryptoManager){ + this.cryptoManager = cryptoManager; + } + @Override + public > PropertyValueConverter getConverter(Class> converterType) { + try { + PropertyValueConverter converter = converterCache.get(converterType); + if(converter != null){ + return ( PropertyValueConverter)converter; + } + Constructor cons = converterType.getConstructor(new Class[]{ CryptoManager.class}); + converter =( PropertyValueConverter)cons.newInstance(cryptoManager); + converterCache.put(converterType, converter); + return ( PropertyValueConverter)converter; + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } + } +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java new file mode 100644 index 000000000..b9080547b --- /dev/null +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java @@ -0,0 +1,118 @@ +package org.springframework.data.couchbase.core.convert; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import org.springframework.core.convert.ConversionService; +import org.springframework.data.convert.PropertyValueConverter; +import org.springframework.data.convert.ValueConversionContext; +import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; +import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.util.Assert; + +import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.java.json.JsonObject; + +public class CryptoConverter implements + PropertyValueConverter>> { + + CryptoManager cryptoManager; + + public CryptoConverter(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + @Override + public Object read(CouchbaseDocument value, ValueConversionContext> context) { + byte[] decrypted = cryptoManager().decrypt(value.export()); + if (decrypted == null) { + return null; + } + // it's decrypted into a String. Now figure out how to convert to the property type. + CouchbaseConversionContext ctx = (CouchbaseConversionContext) context; + CouchbasePersistentProperty property = ctx.getProperty(); + org.springframework.data.convert.CustomConversions conversions = ctx.getConverter().getConversions(); + ConversionService svc = ctx.getConverter().conversionService; + + String decryptedString = new String(decrypted); + if (conversions.isSimpleType(property.getType()) + || conversions.hasCustomReadTarget(String.class, context.getProperty().getType())) { + if (decryptedString.startsWith("\"") && decryptedString.endsWith("\"")) { + decryptedString = decryptedString.substring(1, decryptedString.length() - 1); + } + } + + if (conversions.isSimpleType(property.getType())) { + if (conversions.hasCustomReadTarget(String.class, context.getProperty().getType())) { + try { + return svc.convert(decryptedString, context.getProperty().getType()); + } catch (Exception e) { + System.err.println(e); + } + } + if (conversions.hasCustomReadTarget(Long.class, context.getProperty().getType())) { + try { + return svc.convert(new Long(decryptedString), property.getType()); + } catch (Exception e) { + System.err.println(e); + } + } + if (conversions.hasCustomReadTarget(Double.class, context.getProperty().getType())) { + try { + return svc.convert(new Double(decryptedString), property.getType()); + } catch(Exception e) { + System.err.println(e); + } + throw new RuntimeException("ran out of conversions to try"); + } else { + return ctx.getConverter().getPotentiallyConvertedSimpleRead(decryptedString, property); + } + } else { + CouchbaseDocument decryptedDoc = new CouchbaseDocument().setContent(JsonObject.fromJson(decrypted)); + CouchbasePersistentEntity entity = ctx.getConverter().getMappingContext() + .getRequiredPersistentEntity(property.getType()); + return ctx.getConverter().read(entity, decryptedDoc, null); + } + } + + @Override + public CouchbaseDocument write(Object value, ValueConversionContext> context) { + byte[] plainText; + CouchbaseConversionContext ctx = (CouchbaseConversionContext) context; + CouchbasePersistentProperty property = ctx.getProperty(); + org.springframework.data.convert.CustomConversions conversions = ctx.getConverter().getConversions(); + + Class sourceType = context.getProperty().getType(); + Class targetType = conversions.getCustomWriteTarget(context.getProperty().getType()).orElse(null); + + value = ctx.getConverter().getPotentiallyConvertedSimpleWrite2(property, ctx.getAccessor()); + + if (conversions.isSimpleType(sourceType)) { + String plainString; + if (sourceType == String.class || targetType == String.class) { + plainString = (String) value; + plainString = "\"" + plainString + "\""; + plainText = plainString.getBytes(StandardCharsets.UTF_8); + } else { + plainString = value.toString(); + plainText = plainString.getBytes(StandardCharsets.UTF_8); + } + } else { + plainText = JsonObject.fromJson(context.read(value).toString().getBytes(StandardCharsets.UTF_8)).toBytes(); + } + Map encrypted = cryptoManager().encrypt(plainText, "__DEFAULT__"); + CouchbaseDocument encryptedDoc = new CouchbaseDocument(); + for (Map.Entry entry : encrypted.entrySet()) { + encryptedDoc.put(entry.getKey(), entry.getValue()); + } + return encryptedDoc; + } + + CryptoManager cryptoManager() { + Assert.notNull(cryptoManager, "cryptoManager is null"); + return cryptoManager; + } + +} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java deleted file mode 100644 index e7745cd17..000000000 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/DecryptingReadingConverter.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2022 the original author or authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.couchbase.core.convert; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.HashSet; -import java.util.Set; - -import org.springframework.core.convert.ConversionService; -import org.springframework.core.convert.TypeDescriptor; -import org.springframework.core.convert.converter.ConditionalGenericConverter; -import org.springframework.data.convert.ReadingConverter; -import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; - -import com.couchbase.client.core.encryption.CryptoManager; -import com.couchbase.client.java.encryption.annotation.Encrypted; - -/** - * Use the cryptoManager to decrypt a field - * - * @author Michael Reiche - */ -@ReadingConverter -public class DecryptingReadingConverter implements ConditionalGenericConverter { - - CryptoManager cryptoManager; - ConversionService conversionService; - - public DecryptingReadingConverter(CryptoManager cryptoManager) { - this.cryptoManager = cryptoManager; - } - - @Override - public Set getConvertibleTypes() { - Set convertiblePairs = new HashSet<>(); - Class[] clazzes = new Class[] { String.class, Integer.class, Long.class, Float.class, Double.class, - BigInteger.class, BigDecimal.class, Boolean.class, Enum.class }; - for (Class clazz : clazzes) { - convertiblePairs.add(new ConvertiblePair(CouchbaseDocument.class, clazz)); - } - return convertiblePairs; - } - - @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { - return source == null ? null - : new String(cryptoManager.decrypt(((CouchbaseDocument) source).getContent()), StandardCharsets.UTF_8); - } - - @Override - public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { - return targetType.hasAnnotation(Encrypted.class); - } -} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java deleted file mode 100644 index eeb7240e7..000000000 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/EncryptingWritingConverter.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2012-2022 the original author or authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.couchbase.core.convert; - -import java.math.BigDecimal; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; - -import org.springframework.core.convert.TypeDescriptor; -import org.springframework.core.convert.converter.ConditionalGenericConverter; -import org.springframework.core.convert.converter.GenericConverter; -import org.springframework.data.convert.WritingConverter; - -import com.couchbase.client.core.encryption.CryptoManager; - -/** - * Use the cryptomManager to encrypt properties - * - * @author Michael Reiche - */ -@WritingConverter -public class EncryptingWritingConverter implements ConditionalGenericConverter { - - CryptoManager cryptoManager; - - public EncryptingWritingConverter(CryptoManager cryptoManager) { - this.cryptoManager = cryptoManager; - } - - @Override - public Set getConvertibleTypes() { - - Set convertiblePairs = new HashSet<>(); - Class[] clazzes = new Class[] { String.class, Integer.class, Long.class, Float.class, Double.class, - BigInteger.class, BigDecimal.class, Boolean.class, Enum.class }; - for (Class clazz : clazzes) { - convertiblePairs.add(new ConvertiblePair(clazz, String.class)); - } - return convertiblePairs; - } - - @Override - public Object convert(Object source, TypeDescriptor sourceType, TypeDescriptor targetType) { - if (source == null) { - return null; - } - com.couchbase.client.java.encryption.annotation.Encrypted ann = sourceType - .getAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); - Map result = new HashMap<>(); - result.putAll(cryptoManager.encrypt(source.toString().getBytes(StandardCharsets.UTF_8), ann.encrypter())); - return new Encrypted(result); - } - - @Override - public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { - return sourceType.hasAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); - } -} diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java index 6e9e41a74..1e48d2b1e 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java @@ -23,7 +23,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -38,6 +37,7 @@ import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.PropertyValueConverter; import org.springframework.data.couchbase.core.convert.translation.JacksonTranslationService; import org.springframework.data.couchbase.core.convert.translation.TranslationService; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; @@ -57,6 +57,7 @@ import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.Parameter; import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; import org.springframework.data.mapping.PersistentPropertyAccessor; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.callback.EntityCallbacks; @@ -98,7 +99,6 @@ public class MappingCouchbaseConverter extends AbstractCouchbaseConverter implem * @see #TYPEKEY_SYNCGATEWAY_COMPATIBLE */ public static final String TYPEKEY_DEFAULT = DefaultCouchbaseTypeMapper.DEFAULT_TYPE_KEY; - /** * A "type key" (the name of the field that will hold type information) that is compatible with Sync Gateway (which * doesn't allows underscores). @@ -126,11 +126,6 @@ public class MappingCouchbaseConverter extends AbstractCouchbaseConverter implem */ private @Nullable EntityCallbacks entityCallbacks; - /** - * CryptoManager for en/decryption - */ - private @Nullable CryptoManager cryptoManager; - public MappingCouchbaseConverter() { this(new CouchbaseMappingContext(), null); } @@ -161,6 +156,7 @@ public MappingCouchbaseConverter( // the conversions Service gets them in afterPropertiesSet() CustomConversions customConversions = new CouchbaseCustomConversions(Collections.emptyList()); this.setCustomConversions(customConversions); + // Don't rely on setSimpleTypeHolder being called in afterPropertiesSet() - some integration tests do not use it // if the mappingContext does not have the SimpleTypes, it will not know that they have converters, then it will // try to access the fields of the type and (maybe) fail with InaccessibleObjectException ((CouchbaseMappingContext) mappingContext).setSimpleTypeHolder(customConversions.getSimpleTypeHolder()); @@ -168,23 +164,6 @@ public MappingCouchbaseConverter( spELContext = new SpELContext(CouchbaseDocumentPropertyAccessor.INSTANCE); } - /** - * Get the cryptoManager used in conversions. We need to be able to mangle a property name. - */ - @Override - public void setCustomConversions(CustomConversions customConversions) { - super.setCustomConversions(customConversions); - if (customConversions instanceof CouchbaseCustomConversions) { - this.cryptoManager = ((CouchbaseCustomConversions) customConversions).getCryptoManager(); - } - } - - @Override - public void afterPropertiesSet() { - super.afterPropertiesSet(); - - } - /** * Returns a collection from the given source object. * @@ -273,10 +252,6 @@ protected R read(final TypeInformation type, final CouchbaseDocument sour return read(entity, source, parent); } - private boolean isIdConstructionProperty(final CouchbasePersistentProperty property) { - return property.isAnnotationPresent(IdPrefix.class) || property.isAnnotationPresent(IdSuffix.class); - } - /** * Read an incoming {@link CouchbaseDocument} into the target entity. * @@ -295,7 +270,7 @@ protected R read(final CouchbasePersistentEntity entity, final CouchbaseD final R instance = instantiator.createInstance(entity, provider); final ConvertingPropertyAccessor accessor = getPropertyAccessor(instance); - entity.doWithProperties(new PropertyHandler() { + entity.doWithProperties(new PropertyHandler<>() { @Override public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { if (!doesPropertyExistInSource(prop) || entity.isConstructorArgument(prop) || isIdConstructionProperty(prop) @@ -314,8 +289,7 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { * @return */ private boolean doesPropertyExistInSource(final CouchbasePersistentProperty property) { - return property.isIdProperty() || source.containsKey(property.getFieldName()) - || (cryptoManager != null && source.containsKey(cryptoManager.mangle(property.getFieldName()))); + return property.isIdProperty() || source.containsKey(maybeMangle(property)); } private boolean isIdConstructionProperty(final CouchbasePersistentProperty property) { @@ -445,7 +419,7 @@ private Object getPotentiallyConvertedSimpleRead(final Object value, final Class * @return the potentially converted object. */ @SuppressWarnings("unchecked") - private Object getPotentiallyConvertedSimpleRead(Object value, final CouchbasePersistentProperty target) { + protected Object getPotentiallyConvertedSimpleRead(Object value, final CouchbasePersistentProperty target) { if (value == null || target == null) { return value; } @@ -466,8 +440,7 @@ public void write(final Object source, final CouchbaseDocument target) { typeMapper.writeType(type, target); } - // CouchbasePersistentEntity entity = mappingContext.getPersistentEntity(source.getClass()); - writeInternalRoot(source, target, type, true); + writeInternalRoot(source, target, type, true, null); if (target.getId() == null) { throw new MappingException("An ID property is needed, but not found/could not be generated on this entity."); } @@ -479,10 +452,11 @@ public void write(final Object source, final CouchbaseDocument target) { * @param source the source object. * @param target the target document. * @param withId write out with the id. + * @param property will be null for the root */ @SuppressWarnings("unchecked") protected void writeInternalRoot(final Object source, CouchbaseDocument target, TypeInformation typeHint, - boolean withId) { + boolean withId, CouchbasePersistentProperty property) { if (source == null) { return; } @@ -494,7 +468,7 @@ protected void writeInternalRoot(final Object source, CouchbaseDocument target, } if (Map.class.isAssignableFrom(source.getClass())) { - writeMapInternal((Map) source, target, ClassTypeInformation.MAP, null); + writeMapInternal((Map) source, target, ClassTypeInformation.MAP, property); return; } @@ -503,7 +477,7 @@ protected void writeInternalRoot(final Object source, CouchbaseDocument target, } CouchbasePersistentEntity entity = mappingContext.getPersistentEntity(source.getClass()); - writeInternalEntity(source, target, entity, withId, null); + writeInternalEntity(source, target, entity, withId, property); addCustomTypeKeyIfNecessary(typeHint, source, target); } @@ -589,6 +563,11 @@ public void doWithAssociation(final Association ass } }); + if (prop != null && conversions.hasValueConverter(prop)) { + Map encrypted = (Map) conversions.getPropertyValueConversions() + .getValueConverter(prop).write(source, new CouchbaseConversionContext(prop, this, accessor)); + target.setContent(JsonObject.from(encrypted)); + } } private void writeToTargetDocument(final CouchbaseDocument target, final CouchbasePersistentEntity entity, @@ -648,25 +627,30 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { * @param prop the property information. */ @SuppressWarnings("unchecked") - private void writePropertyInternal(final Object source, final CouchbaseDocument target, + protected void writePropertyInternal(final Object source, final CouchbaseDocument target, final CouchbasePersistentProperty prop, final ConvertingPropertyAccessor accessor) { if (source == null) { return; } + if (conversions.hasValueConverter(prop)) { + Object encrypted = conversions.getPropertyValueConversions().getValueConverter(prop).write(source, + new CouchbaseConversionContext(prop, this, accessor)); + target.put(maybeMangle(prop), encrypted); + return; + } + String name = prop.getFieldName(); TypeInformation valueType = ClassTypeInformation.from(source.getClass()); TypeInformation type = prop.getTypeInformation(); if (valueType.isCollectionLike()) { CouchbaseList collectionDoc = createCollection(asCollection(source), valueType, prop, accessor); - // TODO needs to handle enc target.put(name, collectionDoc); return; } if (valueType.isMap()) { CouchbaseDocument mapDoc = createMap((Map) source, prop); - // TODO needs to handle enc target.put(name, mapDoc); return; } @@ -676,10 +660,8 @@ private void writePropertyInternal(final Object source, final CouchbaseDocument writeSimpleInternal(o.map(s -> prop).orElse(null), accessor, target, prop.getFieldName()); return; } - Optional> basicTargetType = conversions.getCustomWriteTarget(source.getClass()); if (basicTargetType.isPresent()) { - basicTargetType.ifPresent(it -> { target.put(name, conversionService.convert(source, it)); }); @@ -694,17 +676,7 @@ private void writePropertyInternal(final Object source, final CouchbaseDocument ? mappingContext.getRequiredPersistentEntity(source.getClass()) : mappingContext.getRequiredPersistentEntity(prop); writeInternalEntity(source, propertyDoc, entity, false, prop); - com.couchbase.client.java.encryption.annotation.Encrypted ann = prop - .findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class); - if (ann != null) { - JsonObject jo = JsonObject.from(propertyDoc.getContent()); - Map encMap = new HashMap(); - encMap.putAll(cryptoManager.encrypt(jo.toBytes(), ann.encrypter())); - CouchbaseDocument mapDoc = writeMapInternal(encMap, new CouchbaseDocument(), prop.getTypeInformation(), prop); - target.put(cryptoManager.mangle(name), mapDoc); - } else { - target.put(name, propertyDoc); - } + target.put(maybeMangle(prop), propertyDoc); } /** @@ -728,9 +700,9 @@ private CouchbaseDocument createMap(final Map map, final Couchba * @param target the target document. * @return the written couchbase document. */ - private CouchbaseDocument writeMapInternal(final Map source, final CouchbaseDocument target, + private CouchbaseDocument writeMapInternal(final Map source, final CouchbaseDocument target, TypeInformation type, CouchbasePersistentProperty prop) { - for (Map.Entry entry : source.entrySet()) { + for (Map.Entry entry : source.entrySet()) { Object key = entry.getKey(); Object val = entry.getValue(); @@ -745,7 +717,7 @@ private CouchbaseDocument writeMapInternal(final Map source, fin prop.getTypeInformation(), prop, getPropertyAccessor(val))); } else { CouchbaseDocument embeddedDoc = new CouchbaseDocument(); - writeInternalRoot(val, embeddedDoc, prop.getTypeInformation(), false); + writeInternalRoot(val, embeddedDoc, prop.getTypeInformation(), false, prop); target.put(simpleKey, embeddedDoc); } } else { @@ -778,8 +750,6 @@ private CouchbaseList createCollection(final Collection collection, final Typ private CouchbaseList writeCollectionInternal(final Collection source, final CouchbaseList target, final TypeInformation type, CouchbasePersistentProperty prop, ConvertingPropertyAccessor accessor) { - TypeInformation componentType = type == null ? null : type.getComponentType(); - for (Object element : source) { Class elementType = element == null ? null : element.getClass(); @@ -790,7 +760,7 @@ private CouchbaseList writeCollectionInternal(final Collection source, final type, prop, accessor)); } else { CouchbaseDocument embeddedDoc = new CouchbaseDocument(); - writeInternalRoot(element, embeddedDoc, prop.getTypeInformation(), false); + writeInternalRoot(element, embeddedDoc, prop.getTypeInformation(), false, prop); target.put(embeddedDoc); } @@ -863,18 +833,7 @@ private void writeSimpleInternal(final CouchbasePersistentProperty source, Optional optional = (Optional) result; result = optional.orElse(null); } - // For FLE, write as "encrypted$"... - if (source.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null) { - if (result instanceof Encrypted) { - CouchbaseDocument mapDoc = writeMapInternal(((Encrypted) result).getEncryptionMap(), new CouchbaseDocument(), - ClassTypeInformation.MAP, null); - target.put(cryptoManager.mangle(key), mapDoc); - } else { - throw new RuntimeException("field annotation with @Encrypted could not be encrypted " + source.getField()); - } - } else { - target.put(key, result); - } + target.put(maybeMangle(source), result); } public Object getPotentiallyConvertedSimpleWrite(final Object value) { @@ -886,6 +845,11 @@ public Object getPotentiallyConvertedSimpleWrite(final CouchbasePersistentProper return convertForWriteIfNeeded(value, accessor); // can access annotations } + public Object getPotentiallyConvertedSimpleWrite2(final CouchbasePersistentProperty value, + ConvertingPropertyAccessor accessor) { + return convertForWriteIfNeeded2(value, accessor); // can access annotations + } + /** * Add a custom type key if needed. * @@ -963,17 +927,10 @@ private R readValue(Object value, TypeInformation type, Object parent) { @SuppressWarnings("unchecked") private R readValue(Object value, CouchbasePersistentProperty prop, Object parent) { Class rawType = prop.getType(); - - if (prop.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null - && !conversions.isSimpleType(rawType)) { - TreeMap exported = ((CouchbaseDocument) value).export(); // should be couchbase document (?) - byte[] result = cryptoManager.decrypt(exported); - CouchbaseDocument converted = new CouchbaseDocument(); - Object readEntity = read(prop.getTypeInformation().getType(), - (CouchbaseDocument) translationService.decode(new String(result), converted)); - return (R) readEntity; - } - if (conversions.hasCustomReadTarget(value.getClass(), rawType)) { + if (conversions.hasValueConverter(prop)) { + return (R) conversions.getPropertyValueConversions().getValueConverter(prop).read(value, + new CouchbaseConversionContext(prop, this, null)); + } else if (conversions.hasCustomReadTarget(value.getClass(), rawType)) { TypeInformation ti = ClassTypeInformation.from(value.getClass()); return (R) conversionService.convert(value, ti.toTypeDescriptor(), new TypeDescriptor(prop.getField())); } else if (value instanceof CouchbaseDocument) { @@ -1086,12 +1043,7 @@ public CouchbasePropertyValueProvider(final CouchbaseDocument source, @SuppressWarnings("unchecked") public R getPropertyValue(final CouchbasePersistentProperty property) { String expression = property.getSpelExpression(); - Object value = expression != null ? evaluator.evaluate(expression) : source.get(property.getFieldName()); - if (value == null - && property.findAnnotation(com.couchbase.client.java.encryption.annotation.Encrypted.class) != null - && cryptoManager != null) { - value = source.get(cryptoManager.mangle(property.getFieldName())); - } + Object value = expression != null ? evaluator.evaluate(expression) : source.get(maybeMangle(property)); if (property == entity.getIdProperty() && parent == null) { return readValue(source.getId(), property.getTypeInformation(), source); @@ -1103,6 +1055,20 @@ public R getPropertyValue(final CouchbasePersistentProperty property) { } } + private String maybeMangle(PersistentProperty property) { + Assert.notNull(property, "property"); + if (!conversions.hasValueConverter(property)) { + return ((CouchbasePersistentProperty) property).getFieldName(); + } + PropertyValueConverter propertyValueConverter = conversions.getPropertyValueConversions() + .getValueConverter((CouchbasePersistentProperty) property); + CryptoManager cryptoManager = propertyValueConverter != null && propertyValueConverter instanceof CryptoConverter + ? ((CryptoConverter) propertyValueConverter).cryptoManager() + : null; + String fname = ((CouchbasePersistentProperty) property).getFieldName(); + return cryptoManager != null ? cryptoManager.mangle(fname) : fname; + } + /** * A expression parameter value provider. */ diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java index a7a0503cf..fe6488ad8 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java @@ -55,19 +55,6 @@ private OtherConverters() {} return converters; } - /** - * Returns the crypto converters to be registered. - * - * @param cryptoManager to use for encrypting and decrypting - * @return the list of converters to register. - */ - public static Collection getCryptoConverters(CryptoManager cryptoManager) { - List converters = new ArrayList<>(); - converters.add(new EncryptingWritingConverter(cryptoManager)); - converters.add(new DecryptingReadingConverter(cryptoManager)); - return converters; - } - @WritingConverter public enum UuidToString implements Converter { INSTANCE; diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java index cdd47d28c..19bc74589 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/mapping/CouchbaseSimpleTypes.java @@ -16,8 +16,15 @@ package org.springframework.data.couchbase.core.mapping; -import static java.util.stream.Collectors.*; +import static java.util.stream.Collectors.toSet; +import java.math.BigInteger; +import java.time.Instant; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; import java.util.stream.Stream; import org.springframework.data.mapping.model.SimpleTypeHolder; @@ -35,4 +42,40 @@ public abstract class CouchbaseSimpleTypes { private CouchbaseSimpleTypes() {} + public static final Set> AUTOGENERATED_ID_TYPES; + + static { + Set> classes = new HashSet<>(); + classes.add(String.class); + classes.add(BigInteger.class); + AUTOGENERATED_ID_TYPES = Collections.unmodifiableSet(classes); + + Set> simpleTypes = new HashSet<>(); + simpleTypes.add(Pattern.class); + simpleTypes.add(UUID.class); + simpleTypes.add(Instant.class); + + + COUCHBASE_SIMPLE_TYPES = Collections.unmodifiableSet(simpleTypes); + } + + private static final Set> COUCHBASE_SIMPLE_TYPES; + + public static final SimpleTypeHolder HOLDER = new SimpleTypeHolder(COUCHBASE_SIMPLE_TYPES, true) { + + @Override + public boolean isSimpleType(Class type) { + + if (type.isEnum()) { + return true; + } + + if (type.getName().startsWith("java.time")) { + return false; + } + + return super.isSimpleType(type); + } + }; + } diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/ComparableEntity.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/ComparableEntity.java index d2d44a7cd..d94d7fa01 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/ComparableEntity.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/ComparableEntity.java @@ -48,6 +48,7 @@ public boolean equals(Object that) throws RuntimeException { public String toString() throws RuntimeException { Gson gson = new GsonBuilder().create(); - return gson.toJson(this); + String s = gson.toJson(this); + return s; } } diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java index 48fb356f7..7d4d04d1f 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java @@ -17,22 +17,25 @@ package org.springframework.data.couchbase.domain; import java.io.Serializable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; import java.util.ArrayList; +import java.util.Date; import java.util.List; import java.util.Objects; import java.util.UUID; -import com.couchbase.client.java.encryption.annotation.Encrypted; -import org.springframework.data.annotation.CreatedBy; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.annotation.LastModifiedBy; -import org.springframework.data.annotation.LastModifiedDate; +import org.joda.time.DateTime; import org.springframework.data.annotation.PersistenceConstructor; -import org.springframework.data.annotation.Transient; import org.springframework.data.annotation.TypeAlias; import org.springframework.data.annotation.Version; +import org.springframework.data.convert.ValueConverter; +import org.springframework.data.couchbase.core.convert.CryptoConverter; import org.springframework.data.couchbase.core.mapping.Document; +import com.couchbase.client.java.encryption.annotation.Encrypted; + /** * UserEncrypted entity for tests * @@ -44,11 +47,11 @@ @TypeAlias(AbstractingTypeMapper.Type.ABSTRACTUSER) public class UserEncrypted extends AbstractUser implements Serializable { - public UserEncrypted(){ + public UserEncrypted() { this.subtype = AbstractingTypeMapper.Type.USER; } - public UserEncrypted( final String lastname, final String encryptedField) { + public UserEncrypted(final String lastname, final String encryptedField) { this(); this.id = UUID.randomUUID().toString(); this.lastname = lastname; @@ -72,87 +75,54 @@ public UserEncrypted(final String id, final String firstname, final String lastn } @Version protected long version; - @Transient protected String transientInfo; - @CreatedBy protected String createdBy; - @CreatedDate protected long createdDate; - @LastModifiedBy protected String lastModifiedBy; - @LastModifiedDate protected long lastModifiedDate; - @Encrypted public String encryptedField; - @Encrypted public Integer encInteger=1; - @Encrypted public Long encLong=Long.valueOf(1); - @Encrypted public Boolean encBoolean = Boolean.TRUE; - - List nicknames = List.of("Happy", "Sleepy"); - Address homeAddress = new Address(); - List
addresses = new ArrayList<>(); - - @Encrypted - Address encAddress = new Address(); - - public String getLastname() { - return lastname; - } - - public long getCreatedDate() { - return createdDate; - } - - public void setCreatedDate(long createdDate) { - this.createdDate = createdDate; - } - - public String getCreatedBy() { - return createdBy; - } - - public void setCreatedBy(String createdBy) { - this.createdBy = createdBy; - } + @Encrypted @ValueConverter(CryptoConverter.class) public String encryptedField; + @Encrypted @ValueConverter(CryptoConverter.class) public Integer encInteger = 1; + @Encrypted @ValueConverter(CryptoConverter.class) public Long encLong = Long.valueOf(1); + @Encrypted @ValueConverter(CryptoConverter.class) public Boolean encBoolean = Boolean.TRUE; + @Encrypted @ValueConverter(CryptoConverter.class) public BigInteger encBigInteger= new BigInteger("123"); + @Encrypted @ValueConverter(CryptoConverter.class) public BigDecimal encBigDecimal = new BigDecimal("456"); + @Encrypted @ValueConverter(CryptoConverter.class) public UUID encUUID = UUID.randomUUID(); + @Encrypted @ValueConverter(CryptoConverter.class) public Date encDate = Date.from(Instant.now()); + //@Encrypted @ValueConverter(CryptoConverter.class) public DateTime encDateTime = DateTime.now(); - public long getLastModifiedDate() { - return lastModifiedDate; - } + public List nicknames = List.of("Happy", "Sleepy"); - public String getLastModifiedBy() { - return lastModifiedBy; - } + @Encrypted @ValueConverter(CryptoConverter.class) public Address encAddress = new Address(); + public Address homeAddress = null; + public List
addresses = new ArrayList<>(); - public String getEncryptedField() { - return encryptedField; + public String getLastname() { + return lastname; } - public long getVersion() { return version; } + public void setId(String id) { + this.id = id; + } + public void setVersion(long version) { this.version = version; } - public void setHomeAddress(Address address){ + public void setHomeAddress(Address address) { this.homeAddress = address; } - public void setEncAddress(Address address){ + public void setEncAddress(Address address) { this.encAddress = address; } - public void addAddress(Address address){ + public void addAddress(Address address) { this.addresses.add(address); } + @Override public int hashCode() { return Objects.hash(getId(), firstname, lastname); } - public String getTransientInfo() { - return transientInfo; - } - - public void setTransientInfo(String something) { - transientInfo = something; - } - } diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java index bc17cb86b..bf8966d8e 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java @@ -23,12 +23,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.UnsupportedEncodingException; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.security.Provider; import java.util.Base64; import java.util.HashMap; import java.util.Map; @@ -38,36 +34,17 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import com.couchbase.client.encryption.AeadAes256CbcHmacSha512Provider; -import com.couchbase.client.encryption.Decrypter; -import com.couchbase.client.encryption.DefaultCryptoManager; -import com.couchbase.client.encryption.Encrypter; -import com.couchbase.client.encryption.EncryptionResult; -import com.couchbase.client.encryption.Keyring; -import com.couchbase.client.java.encryption.annotation.Encrypted; -import com.couchbase.client.java.encryption.databind.jackson.EncryptionModule; -import com.couchbase.client.java.json.JsonArray; -import com.couchbase.client.java.json.JsonObject; -import com.couchbase.client.java.json.JsonValue; -import com.couchbase.client.java.json.JsonValueModule; -import com.couchbase.client.java.query.QueryOptions; -import com.couchbase.client.java.query.QueryScanConsistency; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; +import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; import org.springframework.data.couchbase.core.CouchbaseTemplate; -import org.springframework.data.couchbase.core.query.N1QLExpression; -import org.springframework.data.couchbase.core.query.N1QLQuery; import org.springframework.data.couchbase.domain.Address; import org.springframework.data.couchbase.domain.PersonValueRepository; -import org.springframework.data.couchbase.domain.User; import org.springframework.data.couchbase.domain.UserEncrypted; import org.springframework.data.couchbase.domain.UserEncryptedRepository; -import org.springframework.data.couchbase.domain.UserRepository; import org.springframework.data.couchbase.repository.config.EnableCouchbaseRepositories; import org.springframework.data.couchbase.util.ClusterAwareIntegrationTests; import org.springframework.data.couchbase.util.ClusterType; @@ -77,8 +54,13 @@ import com.couchbase.client.core.deps.io.netty.handler.ssl.util.InsecureTrustManagerFactory; import com.couchbase.client.core.encryption.CryptoManager; import com.couchbase.client.core.env.SecurityConfig; +import com.couchbase.client.encryption.AeadAes256CbcHmacSha512Provider; +import com.couchbase.client.encryption.Decrypter; +import com.couchbase.client.encryption.DefaultCryptoManager; +import com.couchbase.client.encryption.Encrypter; +import com.couchbase.client.encryption.EncryptionResult; +import com.couchbase.client.encryption.Keyring; import com.couchbase.client.java.env.ClusterEnvironment; -import org.testcontainers.shaded.org.bouncycastle.asn1.isismtt.x509.AdditionalInformationSyntax; /** * Repository KV tests @@ -90,8 +72,8 @@ @IgnoreWhen(clusterTypes = ClusterType.MOCKED) public class CouchbaseRepositoryFieldLevelEncryptionIntegrationTests extends ClusterAwareIntegrationTests { - @Autowired - UserEncryptedRepository userEncryptedRepository; + @Autowired UserEncryptedRepository userEncryptedRepository; + @Autowired CouchbaseClientFactory clientFactory; @Autowired PersonValueRepository personValueRepository; @Autowired CouchbaseTemplate couchbaseTemplate; @@ -108,16 +90,16 @@ void javaSDKEncryption() { } - @Test @IgnoreWhen(clusterTypes = ClusterType.MOCKED) void saveAndFindById() { UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "saveAndFindById", "l", "hello"); Address address = new Address(); // plaintext address with encrypted street - address.setEncStreet("Olcott Street"); + // address.setEncStreet("Olcott Street"); + address.setStreet("Castro Street"); address.setCity("Santa Clara"); user.addAddress(address); - user.setHomeAddress(address); + user.setHomeAddress(null); // cannot set encrypted fields within encrypted objects (i.e. setEncAddress()) Address encAddress = new Address(); // encrypted address with plaintext street. encAddress.setStreet("Castro St"); @@ -130,7 +112,15 @@ void saveAndFindById() { System.err.println(found.get()); found.ifPresent(u -> assertEquals(user, u)); assertTrue(userEncryptedRepository.existsById(user.getId())); - //userEncryptedRepository.delete(user); + + UserEncrypted sdkUser = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAs(UserEncrypted.class); + System.err.println("user: : " + user); + sdkUser.setId(user.getId()); + sdkUser.setVersion(user.getVersion()); + System.err.println("sdkUser : " + sdkUser); + assertEquals(user, sdkUser); + // userEncryptedRepository.delete(user); } @Configuration From ea3e0b68b9eff274b3313f7ed54288b9208fd06d Mon Sep 17 00:00:00 2001 From: mikereiche Date: Mon, 12 Sep 2022 14:24:48 -0700 Subject: [PATCH 3/3] FLE implemenation for Spring Data Couchbase. Closes #763. --- .../AbstractCouchbaseConfiguration.java | 1 - .../convert/AbstractCouchbaseConverter.java | 1 + .../core/convert/CryptoConverter.java | 285 +++++++++----- .../convert/MappingCouchbaseConverter.java | 62 ++- .../core/convert/OtherConverters.java | 74 ++++ .../data/couchbase/domain/Address.java | 1 + .../domain/AddressWithEncStreet.java | 37 ++ .../data/couchbase/domain/TestEncrypted.java | 105 ++++++ .../data/couchbase/domain/UserEncrypted.java | 172 +++++++-- ...yFieldLevelEncryptionIntegrationTests.java | 352 +++++++++++++----- 10 files changed, 880 insertions(+), 210 deletions(-) create mode 100644 spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java create mode 100644 spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java index 9f68a11dc..7cc90f52d 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/config/AbstractCouchbaseConfiguration.java @@ -168,7 +168,6 @@ public ClusterEnvironment couchbaseClusterEnvironment() { * @param builder the builder that can be customized. */ protected void configureEnvironment(final ClusterEnvironment.Builder builder) { - } @Bean(name = BeanNames.COUCHBASE_TEMPLATE) diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java index 66394ec7e..0017c03ea 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/AbstractCouchbaseConverter.java @@ -18,6 +18,7 @@ import java.util.Collections; +import com.couchbase.client.java.query.QueryScanConsistency; import org.springframework.beans.factory.InitializingBean; import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.TypeDescriptor; diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java index 1075a3f9b..8b16468d7 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/CryptoConverter.java @@ -15,24 +15,31 @@ */ package org.springframework.data.couchbase.core.convert; -import java.lang.reflect.Constructor; -import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Optional; +import org.springframework.core.convert.ConversionFailedException; import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.ConverterNotFoundException; +import org.springframework.data.convert.CustomConversions; import org.springframework.data.convert.PropertyValueConverter; import org.springframework.data.convert.ValueConversionContext; import org.springframework.data.couchbase.core.mapping.CouchbaseDocument; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentEntity; import org.springframework.data.couchbase.core.mapping.CouchbasePersistentProperty; import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; import org.springframework.util.Assert; import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.core.error.InvalidArgumentException; +import com.couchbase.client.java.json.JsonArray; import com.couchbase.client.java.json.JsonObject; +import com.couchbase.client.java.json.JsonValue; /** * Encrypt/Decrypted properties annotated with @@ -54,114 +61,216 @@ public Object read(CouchbaseDocument value, ValueConversionContext> context) { CouchbaseConversionContext ctx = (CouchbaseConversionContext) context; CouchbasePersistentProperty property = ctx.getProperty(); - org.springframework.data.convert.CustomConversions conversions = ctx.getConverter().getConversions(); - ConversionService svc = ctx.getConverter().conversionService; + byte[] plainText = coerceToBytesWrite(property, ctx.getAccessor(), ctx); + Map encrypted = cryptoManager().encrypt(plainText, CryptoManager.DEFAULT_ENCRYPTER_ALIAS); + return new CouchbaseDocument().setContent(encrypted); + } + + private Object coerceToValueRead(byte[] decrypted, CouchbaseConversionContext context) { + CouchbasePersistentProperty property = context.getProperty(); + + CustomConversions cnvs = context.getConverter().getConversions(); + ConversionService svc = context.getConverter().getConversionService(); + Class type = property.getType(); - boolean wasString = false; - List exceptionList = new LinkedList<>(); String decryptedString = new String(decrypted); - if (conversions.isSimpleType(property.getType()) - || conversions.hasCustomReadTarget(String.class, context.getProperty().getType())) { - if (decryptedString.startsWith("\"") && decryptedString.endsWith("\"")) { - decryptedString = decryptedString.substring(1, decryptedString.length() - 1); - decryptedString = decryptedString.replaceAll("\\\"", "\""); - wasString = true; - } + if ("null".equals(decryptedString)) { + return null; } + /* this what we would do if we could use a JsonParser with a beanPropertyTypeRef + final JsonParser plaintextParser = p.getCodec().getFactory().createParser(plaintext); + plaintextParser.setCodec(p.getCodec()); + + return plaintextParser.readValueAs(beanPropertyTypeRef); + */ - if (conversions.isSimpleType(property.getType())) { - if (wasString && conversions.hasCustomReadTarget(String.class, context.getProperty().getType())) { - try { - return svc.convert(decryptedString, context.getProperty().getType()); - } catch (Exception e) { - exceptionList.add(e); - } - } - if (conversions.hasCustomReadTarget(Long.class, context.getProperty().getType())) { - try { - return svc.convert(Long.valueOf(decryptedString), property.getType()); - } catch (Exception e) { - exceptionList.add(e); - } - } - if (conversions.hasCustomReadTarget(Double.class, context.getProperty().getType())) { - try { - return svc.convert(Double.valueOf(decryptedString), property.getType()); - } catch (Exception e) { - exceptionList.add(e); - } - } - if (conversions.hasCustomReadTarget(Boolean.class, context.getProperty().getType())) { - try { - Object booleanResult = svc.convert(Boolean.valueOf(decryptedString), property.getType()); - if (booleanResult == null) { - throw new Exception("value " + decryptedString + " would not convert to boolean"); - } - } catch (Exception e) { - exceptionList.add(e); - } - } - // let's try to find a constructor... - try { - Constructor constructor = context.getProperty().getType().getConstructor(String.class); - return constructor.newInstance(decryptedString); - } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { - exceptionList.add(new Exception("tried to instantiate from constructor taking string arg but got " + e)); - } - // last chance... + if (!cnvs.isSimpleType(type) && !type.isArray()) { + JsonObject jo = JsonObject.fromJson(decryptedString); + CouchbaseDocument source = new CouchbaseDocument().setContent(jo); + return context.getConverter().read(property.getTypeInformation(), source); + } else { + String jsonString = "{\"" + property.getFieldName() + "\":" + decryptedString + "}"; try { - return ctx.getConverter().getPotentiallyConvertedSimpleRead(decryptedString, property); - } catch (Exception e) { - exceptionList.add(e); - RuntimeException ee = new RuntimeException( - "failed to convert " + decryptedString + " due to the following suppressed reasons(s): "); - exceptionList.stream().forEach(ee::addSuppressed); - throw ee; + CouchbaseDocument decryptedDoc = new CouchbaseDocument().setContent(JsonObject.fromJson(jsonString)); + return context.getConverter().getPotentiallyConvertedSimpleRead(decryptedDoc.get(property.getFieldName()), + property); + } catch (InvalidArgumentException | ConverterNotFoundException | ConversionFailedException e) { + throw new RuntimeException(decryptedString, e); } - - } else { - CouchbaseDocument decryptedDoc = new CouchbaseDocument().setContent(JsonObject.fromJson(decrypted)); - CouchbasePersistentEntity entity = ctx.getConverter().getMappingContext() - .getRequiredPersistentEntity(property.getType()); - return ctx.getConverter().read(entity, decryptedDoc, null); } } - @Override - public CouchbaseDocument write(Object value, ValueConversionContext> context) { + private byte[] coerceToBytesWrite(CouchbasePersistentProperty property, ConvertingPropertyAccessor accessor, + CouchbaseConversionContext context) { byte[] plainText; - CouchbaseConversionContext ctx = (CouchbaseConversionContext) context; - CouchbasePersistentProperty property = ctx.getProperty(); - org.springframework.data.convert.CustomConversions conversions = ctx.getConverter().getConversions(); - - Class sourceType = context.getProperty().getType(); - Class targetType = conversions.getCustomWriteTarget(context.getProperty().getType()).orElse(null); + CustomConversions cnvs = context.getConverter().getConversions(); - value = ctx.getConverter().getPotentiallyConvertedSimpleWrite(property, ctx.getAccessor(), false); - if (conversions.isSimpleType(sourceType)) { - String plainString; - plainString = (String) value; - if (sourceType == String.class || targetType == String.class) { + Class sourceType = property.getType(); + Class targetType = cnvs.getCustomWriteTarget(property.getType()).orElse(null); + Object value = context.getConverter().getPotentiallyConvertedSimpleWrite(property, accessor, false); + if (value == null) { // null + plainText = "null".getBytes(StandardCharsets.UTF_8); + } else if (value.getClass().isArray()) { // array + JsonArray ja; + if (value.getClass().getComponentType().isPrimitive()) { + ja = jaFromPrimitiveArray(value); + } else { + ja = jaFromObjectArray(value, context.getConverter()); + } + plainText = ja.toBytes(); + } else if (cnvs.isSimpleType(sourceType)) { // simpleType + String plainString = value != null ? value.toString() : null; + if ((sourceType == String.class || targetType == String.class) || sourceType == Character.class + || sourceType == char.class || Enum.class.isAssignableFrom(sourceType) + || Locale.class.isAssignableFrom(sourceType)) { + // TODO use jackson serializer here plainString = "\"" + plainString.replaceAll("\"", "\\\"") + "\""; } plainText = plainString.getBytes(StandardCharsets.UTF_8); - } else { + } else { // an entity plainText = JsonObject.fromJson(context.read(value).toString().getBytes(StandardCharsets.UTF_8)).toBytes(); } - Map encrypted = cryptoManager().encrypt(plainText, CryptoManager.DEFAULT_ENCRYPTER_ALIAS); - CouchbaseDocument encryptedDoc = new CouchbaseDocument(); - for (Map.Entry entry : encrypted.entrySet()) { - encryptedDoc.put(entry.getKey(), entry.getValue()); - } - return encryptedDoc; + return plainText; } CryptoManager cryptoManager() { - Assert.notNull(cryptoManager, "cryptoManager is null"); + Assert.notNull(cryptoManager, + "cryptoManager needed to encrypt/decrypt but it is null. Override needed for cryptoManager() method of " + + AbstractCouchbaseConverter.class.getName()); return cryptoManager; } + JsonArray jaFromObjectArray(Object value, MappingCouchbaseConverter converter) { + CustomConversions cnvs = converter.getConversions(); + ConversionService svc = converter.getConversionService(); + JsonArray ja = JsonArray.ja(); + for (Object o : (Object[]) value) { + ja.add(coerceToJson(o, cnvs, svc)); + } + return ja; + } + + JsonArray jaFromPrimitiveArray(Object value) { + Class component = value.getClass().getComponentType(); + JsonArray jArray; + if (Long.TYPE.isAssignableFrom(component)) { + jArray = ja_long((long[]) value); + } else if (Integer.TYPE.isAssignableFrom(component)) { + jArray = ja_int((int[]) value); + } else if (Double.TYPE.isAssignableFrom(component)) { + jArray = ja_double((double[]) value); + } else if (Float.TYPE.isAssignableFrom(component)) { + jArray = ja_float((float[]) value); + } else if (Boolean.TYPE.isAssignableFrom(component)) { + jArray = ja_boolean((boolean[]) value); + } else if (Short.TYPE.isAssignableFrom(component)) { + jArray = ja_short((short[]) value); + } else if (Byte.TYPE.isAssignableFrom(component)) { + jArray = ja_byte((byte[]) value); + } else if (Character.TYPE.isAssignableFrom(component)) { + jArray = ja_char((char[]) value); + } else { + throw new RuntimeException("unhandled primitive array: " + component.getName()); + } + return jArray; + } + + JsonArray ja_long(long[] array) { + JsonArray ja = JsonArray.ja(); + for (long t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_int(int[] array) { + JsonArray ja = JsonArray.ja(); + for (int t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_double(double[] array) { + JsonArray ja = JsonArray.ja(); + for (double t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_float(float[] array) { + JsonArray ja = JsonArray.ja(); + for (float t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_boolean(boolean[] array) { + JsonArray ja = JsonArray.ja(); + for (boolean t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_short(short[] array) { + JsonArray ja = JsonArray.ja(); + for (short t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_byte(byte[] array) { + JsonArray ja = JsonArray.ja(); + for (byte t : array) { + ja.add(t); + } + return ja; + } + + JsonArray ja_char(char[] array) { + JsonArray ja = JsonArray.ja(); + for (char t : array) { + ja.add(String.valueOf(t)); + } + return ja; + } + + Object coerceToJson(Object o, CustomConversions cnvs, ConversionService svc) { + if (o != null && o.getClass() == Optional.class) { + o = ((Optional) o).isEmpty() ? null : ((Optional) o).get(); + } + Optional> clazz; + if (o == null) { + o = JsonValue.NULL; + } else if ((clazz = cnvs.getCustomWriteTarget(o.getClass())).isPresent()) { + o = svc.convert(o, clazz.get()); + } else if (JsonObject.checkType(o)) { + // The object is of an acceptable type + } else if (Number.class.isAssignableFrom(o.getClass())) { + if (o.toString().contains(".")) { + o = ((Number) o).doubleValue(); + } else { + o = ((Number) o).longValue(); + } + } else if (Character.class.isAssignableFrom(o.getClass())) { + o = ((Character) o).toString(); + } else if (Enum.class.isAssignableFrom(o.getClass())) { + o = ((Enum) o).name(); + } else { // punt + o = o.toString(); + } + return o; + } } diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java index 539b1e039..0733b8b52 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/MappingCouchbaseConverter.java @@ -76,6 +76,7 @@ import org.springframework.util.CollectionUtils; import com.couchbase.client.core.encryption.CryptoManager; +import com.couchbase.client.java.encryption.annotation.Encrypted; import com.couchbase.client.java.json.JsonObject; /** @@ -247,6 +248,8 @@ protected R read(final TypeInformation type, final CouchbaseDocument sour CouchbasePersistentEntity entity = (CouchbasePersistentEntity) mappingContext .getRequiredPersistentEntity(typeToUse); + if (source.containsKey("encbooleans")) + System.err.println(source); return read(entity, source, parent); } @@ -275,8 +278,10 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { || prop.isAnnotationPresent(N1qlJoin.class)) { return; } + Object obj = prop == entity.getIdProperty() && parent == null ? source.getId() : getValueInternal(prop, source, instance, entity); + accessor.setProperty(prop, obj); } @@ -287,7 +292,8 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { * @return */ private boolean doesPropertyExistInSource(final CouchbasePersistentProperty property) { - return property.isIdProperty() || source.containsKey(maybeMangle(property)); + return property.isIdProperty() || source.containsKey(property.getFieldName()) + || source.containsKey(maybeMangle(property)); } private boolean isIdConstructionProperty(final CouchbasePersistentProperty property) { @@ -607,7 +613,7 @@ public void doWithPersistentProperty(final CouchbasePersistentProperty prop) { return; } - if (!conversions.isSimpleType(propertyObj.getClass())) { + if (!conversions.isSimpleType(prop.getType())) { writePropertyInternal(propertyObj, target, prop, accessor); } else { writeSimpleInternal(prop, accessor, target, prop.getFieldName()); @@ -631,25 +637,23 @@ protected void writePropertyInternal(final Object source, final CouchbaseDocumen return; } - if (conversions.hasValueConverter(prop)) { // property is encrypted - Object encrypted = conversions.getPropertyValueConversions().getValueConverter(prop).write(source, - new CouchbaseConversionContext(prop, this, accessor)); - target.put(maybeMangle(prop), encrypted); - return; - } - String name = prop.getFieldName(); TypeInformation valueType = ClassTypeInformation.from(source.getClass()); TypeInformation type = prop.getTypeInformation(); if (valueType.isCollectionLike()) { CouchbaseList collectionDoc = createCollection(asCollection(source), valueType, prop, accessor); - target.put(name, collectionDoc); + putMaybeEncrypted(target, prop, collectionDoc, accessor); return; } if (valueType.isMap()) { CouchbaseDocument mapDoc = createMap((Map) source, prop); - target.put(name, mapDoc); + putMaybeEncrypted(target, prop, mapDoc, accessor); + return; + } + + if (conversions.hasValueConverter(prop)) { // property is encrypted + putMaybeEncrypted(target, prop, source, accessor); return; } @@ -658,6 +662,7 @@ protected void writePropertyInternal(final Object source, final CouchbaseDocumen writeSimpleInternal(o.map(s -> prop).orElse(null), accessor, target, prop.getFieldName()); return; } + Optional> basicTargetType = conversions.getCustomWriteTarget(source.getClass()); if (basicTargetType.isPresent()) { basicTargetType.ifPresent(it -> { @@ -677,6 +682,15 @@ protected void writePropertyInternal(final Object source, final CouchbaseDocumen target.put(maybeMangle(prop), propertyDoc); } + private void putMaybeEncrypted(CouchbaseDocument target, CouchbasePersistentProperty prop, Object value, + ConvertingPropertyAccessor accessor) { + if (conversions.hasValueConverter(prop)) { // property is encrypted + value = conversions.getPropertyValueConversions().getValueConverter(prop).write(value, + new CouchbaseConversionContext(prop, this, accessor)); + } + target.put(maybeMangle(prop), value); + } + /** * Wrapper method to create the underlying map. * @@ -937,9 +951,9 @@ private R readValue(Object value, TypeInformation type, Object parent) { * @return the converted object. */ @SuppressWarnings("unchecked") - private R readValue(Object value, CouchbasePersistentProperty prop, Object parent) { + public R readValue(Object value, CouchbasePersistentProperty prop, Object parent, boolean noDecrypt) { Class rawType = prop.getType(); - if (conversions.hasValueConverter(prop)) { + if (conversions.hasValueConverter(prop) && !noDecrypt) { return (R) conversions.getPropertyValueConversions().getValueConverter(prop).read(value, new CouchbaseConversionContext(prop, this, null)); } else if (conversions.hasCustomReadTarget(value.getClass(), rawType)) { @@ -1055,7 +1069,23 @@ public CouchbasePropertyValueProvider(final CouchbaseDocument source, @SuppressWarnings("unchecked") public R getPropertyValue(final CouchbasePersistentProperty property) { String expression = property.getSpelExpression(); - Object value = expression != null ? evaluator.evaluate(expression) : source.get(maybeMangle(property)); + String maybeFieldName = maybeMangle(property); + Object value = expression != null ? evaluator.evaluate(expression) : source.get(maybeFieldName); + boolean noDecrypt = false; + + // handle @Encrypted FROM_UNENCRYPTED. Just accept them as-is. + if (property.findAnnotation(Encrypted.class) != null) { + if (value == null && !maybeFieldName.equals(property.getFieldName()) + && property.findAnnotation(Encrypted.class).migration().equals(Encrypted.Migration.FROM_UNENCRYPTED)) { + value = source.get(property.getFieldName()); + noDecrypt = true; + } else if (value != null + && !((value instanceof CouchbaseDocument) && (((CouchbaseDocument) value)).containsKey("kid"))) { + noDecrypt = true; + // TODO - should we throw an exception, or just ignore the problem of not being encrypted with noDecrypt=true? + throw new RuntimeException("should have been encrypted, but is not " + maybeFieldName); + } + } if (property == entity.getIdProperty() && parent == null) { return readValue(source.getId(), property.getTypeInformation(), source); @@ -1063,11 +1093,11 @@ public R getPropertyValue(final CouchbasePersistentProperty property) { if (value == null) { return null; } - return readValue(value, property, source); + return readValue(value, property, source, noDecrypt); } } - private String maybeMangle(PersistentProperty property) { + String maybeMangle(PersistentProperty property) { Assert.notNull(property, "property"); if (!conversions.hasValueConverter(property)) { return ((CouchbasePersistentProperty) property).getFieldName(); diff --git a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java index 8e95fdb6e..b154366a9 100644 --- a/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java +++ b/spring-data-couchbase/src/main/java/org/springframework/data/couchbase/core/convert/OtherConverters.java @@ -18,6 +18,7 @@ import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -26,6 +27,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; +import org.springframework.util.Base64Utils; /** * Out of the box conversions for java dates and calendars. @@ -50,6 +52,12 @@ private OtherConverters() {} converters.add(StringToBigInteger.INSTANCE); converters.add(BigDecimalToString.INSTANCE); converters.add(StringToBigDecimal.INSTANCE); + converters.add(ByteArrayToString.INSTANCE); + converters.add(StringToByteArray.INSTANCE); + converters.add(CharArrayToString.INSTANCE); + converters.add(StringToCharArray.INSTANCE); + converters.add(ClassToString.INSTANCE); + converters.add(StringToClass.INSTANCE); return converters; } @@ -113,4 +121,70 @@ public BigDecimal convert(String source) { return source == null ? null : new BigDecimal(source); } } + + @WritingConverter + public enum ByteArrayToString implements Converter { + INSTANCE; + + @Override + public String convert(byte[] source) { + return source == null ? null : Base64Utils.encodeToString(source); + } + } + + @ReadingConverter + public enum StringToByteArray implements Converter { + INSTANCE; + + @Override + public byte[] convert(String source) { + return source == null ? null : Base64Utils.decode(source.getBytes(StandardCharsets.UTF_8)); + } + } + + @WritingConverter + public enum CharArrayToString implements Converter { + INSTANCE; + + @Override + public String convert(char[] source) { + return source == null ? null : new String(source) ; + } + } + + @ReadingConverter + public enum StringToCharArray implements Converter { + INSTANCE; + + @Override + public char[] convert(String source) { + return source == null ? null : source.toCharArray(); + } + } + + + @WritingConverter + public enum ClassToString implements Converter, String> { + INSTANCE; + + @Override + public String convert(Class source) { + return source == null ? null : source.getClass().getName() ; + } + } + + @ReadingConverter + public enum StringToClass implements Converter> { + INSTANCE; + + @Override + public Class convert(String source) { + try { + return source == null ? null : Class.forName(source); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + } + } + } diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java index 59b5313b2..dc761dd32 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/Address.java @@ -15,6 +15,7 @@ */ package org.springframework.data.couchbase.domain; +import com.couchbase.client.java.encryption.annotation.Encrypted; import org.springframework.data.couchbase.core.mapping.Document; @Document diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java new file mode 100644 index 000000000..93422d642 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/AddressWithEncStreet.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020-2021 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.couchbase.domain; + +import org.springframework.data.couchbase.core.mapping.Document; + +import com.couchbase.client.java.encryption.annotation.Encrypted; + +@Document +public class AddressWithEncStreet extends Address { + + private @Encrypted String encStreet; + + public AddressWithEncStreet() {} + + public String getEncStreet() { + return encStreet; + } + + public void setEncStreet(String encStreet) { + this.encStreet = encStreet; + } + +} diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java new file mode 100644 index 000000000..11bc775a8 --- /dev/null +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/TestEncrypted.java @@ -0,0 +1,105 @@ +/* + * Copyright 2012-2022 the original author or authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.data.couchbase.domain; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.annotation.TypeAlias; +import org.springframework.data.annotation.Version; +import org.springframework.data.couchbase.core.mapping.Document; + +import com.couchbase.client.java.encryption.annotation.Encrypted; +import com.couchbase.client.java.query.QueryScanConsistency; + +/** + * UserEncrypted entity for tests + * + * @author Michael Nitschinger + * @author Michael Reiche + */ + +@Document +public class TestEncrypted implements Serializable { + + public String id; + @Encrypted + public byte[] encString={1,2,3,4}; + + public TestEncrypted() { + } + + public TestEncrypted(final String id) { + this(); + this.id = id; + } + + public String getId() { + return id; + } + public void setId(String id) { + this.id = id; + } + + + public String toString(){ + StringBuffer sb=new StringBuffer(); + sb.append("encString: "+encToString()); + return sb.toString(); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + public void initSimpleTypes(){ + + } + + @Override public boolean equals(Object o){ + if(o == null || o.getClass() != getClass()){ + return false; + } + TestEncrypted other = (TestEncrypted) o; + //return this.encString == other.encString; + if(other.encString == null && this.encString != null) + return false; + return other.encString.equals(this.encString); + } + + public String encToString(){ + StringBuffer sb = new StringBuffer(); + for(byte c:encString){ + if(!sb.isEmpty()) + sb.append(","); + sb.append(c); + } + return sb.toString(); + } +} diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java index 4c79ff794..37a0720e4 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/domain/UserEncrypted.java @@ -23,6 +23,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.UUID; @@ -34,6 +35,7 @@ import org.springframework.data.couchbase.core.mapping.Document; import com.couchbase.client.java.encryption.annotation.Encrypted; +import com.couchbase.client.java.query.QueryScanConsistency; /** * UserEncrypted entity for tests @@ -47,15 +49,11 @@ public class UserEncrypted extends AbstractUser implements Serializable { public UserEncrypted() { + this._class = "abstractuser"; this.subtype = AbstractingTypeMapper.Type.USER; } - public UserEncrypted(final String lastname, final String encryptedField) { - this(); - this.id = UUID.randomUUID().toString(); - this.lastname = lastname; - this.encryptedField = encryptedField; - } + public String _class; // cheat a little so that will work with Java SDK @PersistenceConstructor public UserEncrypted(final String id, final String firstname, final String lastname) { @@ -73,26 +71,81 @@ public UserEncrypted(final String id, final String firstname, final String lastn this.encryptedField = encryptedField; } + static DateTime NOW_DateTime = DateTime.now(DateTimeZone.UTC); + static Date NOW_Date = Date.from(Instant.now()); @Version protected long version; - @Encrypted public String encryptedField; - @Encrypted public Integer encInteger = 1; - @Encrypted public Long encLong = Long.valueOf(1); - @Encrypted public Boolean encBoolean = Boolean.TRUE; - @Encrypted public BigInteger encBigInteger = new BigInteger("123"); - @Encrypted public BigDecimal encBigDecimal = new BigDecimal("456"); - @Encrypted public UUID encUUID = UUID.randomUUID(); - @Encrypted public DateTime encDateTime = DateTime.now(DateTimeZone.UTC); - @Encrypted public Date encDate = Date.from(Instant.now()); - @Encrypted public Address encAddress = new Address(); - public Date plainDate = Date.from(Instant.now()); - public DateTime plainDateTime = DateTime.now(DateTimeZone.UTC); + @Encrypted(migration = Encrypted.Migration.FROM_UNENCRYPTED) public String encryptedField; + + @Encrypted public boolean encboolean; + @Encrypted public boolean[] encbooleans; + @Encrypted public Boolean encBoolean; + @Encrypted public Boolean[] encBooleans; + + @Encrypted public long enclong; + @Encrypted public long[] enclongs; + @Encrypted public Long encLong; + @Encrypted public Long[] encLongs; + + @Encrypted public short encshort; + @Encrypted public short[] encshorts; + @Encrypted public Short encShort; + @Encrypted public Short[] encShorts; + + @Encrypted public int encinteger; + @Encrypted public int[] encintegers; + @Encrypted public Integer encInteger; + @Encrypted public Integer[] encIntegers; + + @Encrypted public byte encbyte; + public byte[] plainbytes; + @Encrypted public byte[] encbytes; + @Encrypted public Byte encByte; + @Encrypted public Byte[] encBytes; + + @Encrypted public float encfloat; + @Encrypted public float[] encfloats; + @Encrypted public Float encFloat; + @Encrypted public Float[] encFloats; - public List nicknames = List.of("Happy", "Sleepy"); + @Encrypted public double encdouble; + @Encrypted public double[] encdoubles; + @Encrypted public Double encDouble; + @Encrypted public Double[] encDoubles; + @Encrypted public char encchar='x'; // need to initialize as char(0) is not legal + @Encrypted public char[] encchars; + @Encrypted public Character encCharacter; + @Encrypted public Character[] encCharacters; + + @Encrypted public String encString; + @Encrypted public String[] encStrings; + + @Encrypted public Date encDate; + @Encrypted public Date[] encDates; + + @Encrypted public Locale encLocal; + @Encrypted public Locale[] encLocales; + + @Encrypted public QueryScanConsistency encEnum; + @Encrypted public QueryScanConsistency[] encEnums; + + @Encrypted public Class clazz; + + @Encrypted public BigInteger encBigInteger; + @Encrypted public BigDecimal encBigDecimal; + @Encrypted public UUID encUUID; + @Encrypted public DateTime encDateTime; + + @Encrypted public Address encAddress = new Address(); + + public Date plainDate; + public DateTime plainDateTime; + + public List nicknames; public Address homeAddress = null; - public List
addresses = new ArrayList<>(); + public List addresses = new ArrayList<>(); public String getLastname() { return lastname; @@ -118,13 +171,90 @@ public void setEncAddress(Address address) { this.encAddress = address; } - public void addAddress(Address address) { + public void addAddress(AddressWithEncStreet address) { this.addresses.add(address); } + public UserEncrypted withClass(String _class) { + this._class = _class; + return this; + } + @Override public int hashCode() { return Objects.hash(getId(), firstname, lastname); } + public void initSimpleTypes() { + + encboolean = false; + encbooleans = new boolean[] { true, false }; + encBoolean = true; + encBooleans = new Boolean[] { true, false }; + + enclong = 1; + enclongs = new long[] { 1, 2 }; + encLong = Long.valueOf(1); + encLongs = new Long[] { Long.valueOf(1), Long.valueOf(2) }; + + encshort = 1; + encshorts = new short[] { 3, 4 }; + encShort = 5; + encShorts = new Short[] { 6, 7 }; + + encinteger = 1; + encintegers = new int[] { 2, 3 }; + encInteger = 4; + encIntegers = new Integer[] { 5, 6 }; + + encbyte = 32; + encbytes = new byte[] { 1, 2, 3, 4 }; + plainbytes = new byte[] { 1, 2, 3, 4 }; + encByte = 48; + encBytes = new Byte[] { 4, 5, 6, 7 }; + + encfloat = 1; + encfloats = new float[] { 1, 2 }; + encFloat = Float.valueOf("1.1"); + encFloats = new Float[] { encFloat }; + + encdouble = 1.2; + encdoubles = new double[] { 3.4, 5.6 }; + encDouble = 7.8; + encDoubles = new Double[] { 9.10, 11.12 }; + + encchar = 'a'; + encchars = new char[] { 'b', 'c', 'd' }; + encCharacter = 'a'; + encCharacters = new Character[] { 'a', 'b' }; + + encString = "myString"; + encStrings = new String[] { "myString" }; + + encDate = NOW_Date; + encDates = new Date[] { NOW_Date }; + + encLocal = Locale.US; + encLocales = new Locale[] { Locale.US }; + + encEnum = QueryScanConsistency.NOT_BOUNDED; + encEnums = new QueryScanConsistency[] { QueryScanConsistency.NOT_BOUNDED }; + + encBigInteger = new BigInteger("123"); + encBigDecimal = new BigDecimal("456"); + + encUUID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + //clazz = String.class; + + encDateTime = NOW_DateTime; + + encAddress = new Address(); + + plainDate = NOW_Date; + plainDateTime = NOW_DateTime; + + nicknames = List.of("Happy", "Sleepy"); + + } } diff --git a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java index d9a2fa8d3..2326ccc84 100644 --- a/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java +++ b/spring-data-couchbase/src/test/java/org/springframework/data/couchbase/repository/CouchbaseRepositoryFieldLevelEncryptionIntegrationTests.java @@ -16,36 +16,29 @@ package org.springframework.data.couchbase.repository; -import static com.couchbase.client.core.util.CbCollections.mapOf; import static com.couchbase.client.java.query.QueryScanConsistency.REQUEST_PLUS; +import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.UnsupportedEncodingException; -import java.security.InvalidKeyException; -import java.security.NoSuchAlgorithmException; -import java.util.Base64; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.joda.JodaModule; -import org.joda.time.DateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; +import org.springframework.dao.DataRetrievalFailureException; import org.springframework.data.couchbase.CouchbaseClientFactory; import org.springframework.data.couchbase.config.AbstractCouchbaseConfiguration; import org.springframework.data.couchbase.core.CouchbaseTemplate; import org.springframework.data.couchbase.domain.Address; -import org.springframework.data.couchbase.domain.PersonValueRepository; +import org.springframework.data.couchbase.domain.AddressWithEncStreet; +import org.springframework.data.couchbase.domain.TestEncrypted; import org.springframework.data.couchbase.domain.UserEncrypted; import org.springframework.data.couchbase.domain.UserEncryptedRepository; import org.springframework.data.couchbase.repository.config.EnableCouchbaseRepositories; @@ -58,12 +51,12 @@ import com.couchbase.client.core.encryption.CryptoManager; import com.couchbase.client.core.env.SecurityConfig; import com.couchbase.client.encryption.AeadAes256CbcHmacSha512Provider; -import com.couchbase.client.encryption.Decrypter; import com.couchbase.client.encryption.DefaultCryptoManager; -import com.couchbase.client.encryption.Encrypter; -import com.couchbase.client.encryption.EncryptionResult; import com.couchbase.client.encryption.Keyring; import com.couchbase.client.java.env.ClusterEnvironment; +import com.couchbase.client.java.json.JsonObject; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.joda.JodaModule; /** * Repository KV tests @@ -78,13 +71,21 @@ public class CouchbaseRepositoryFieldLevelEncryptionIntegrationTests extends Clu @Autowired UserEncryptedRepository userEncryptedRepository; @Autowired CouchbaseClientFactory clientFactory; - @Autowired PersonValueRepository personValueRepository; @Autowired CouchbaseTemplate couchbaseTemplate; @BeforeEach public void beforeEach() { super.beforeEach(); - couchbaseTemplate.removeByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); + List users = couchbaseTemplate.findByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); + for (UserEncrypted user : users) { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + try { // may have also used upperCased-id + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + couchbaseTemplate.removeByQuery(UserEncrypted.class).all(); couchbaseTemplate.findByQuery(UserEncrypted.class).withConsistency(REQUEST_PLUS).all(); } @@ -95,47 +96,227 @@ void javaSDKEncryption() { @Test @IgnoreWhen(clusterTypes = ClusterType.MOCKED) - void saveAndFindById() { - UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "saveAndFindById", "l", "hello"); - Address address = new Address(); // plaintext address with encrypted street - // address.setEncStreet("Olcott Street"); - address.setStreet("Castro Street"); + void saveAndFindByTestId() { + TestEncrypted user = new TestEncrypted(UUID.randomUUID().toString()); + user.initSimpleTypes(); + couchbaseTemplate.save(user); + TestEncrypted writeSpringReadSpring = couchbaseTemplate.findById(TestEncrypted.class).one(user.id); + System.err.println(user); + System.err.println(writeSpringReadSpring); + assertEquals(user.toString(), writeSpringReadSpring.toString()); + + TestEncrypted writeSpringReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.id).contentAs(TestEncrypted.class); + writeSpringReadSDK.setId(user.id); + assertEquals(user.toString(), writeSpringReadSDK.toString()); + + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), + user); + TestEncrypted writeSDKReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId().toUpperCase()).contentAs(TestEncrypted.class); + writeSDKReadSDK.setId(user.getId()); + assertEquals(user.toString(), writeSDKReadSDK.toString()); + + TestEncrypted writeSDKReadSpring = couchbaseTemplate.findById(TestEncrypted.class).one(user.getId().toUpperCase()); + writeSDKReadSpring.setId(user.getId()); + + assertEquals(user.toString(), writeSDKReadSpring.toString()); + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSpring_readSpring() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSpring_readSpring", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); address.setCity("Santa Clara"); user.addAddress(address); user.setHomeAddress(null); - // cannot set encrypted fields within encrypted objects (i.e. setEncAddress()) Address encAddress = new Address(); // encrypted address with plaintext street. encAddress.setStreet("Castro St"); encAddress.setCity("Mountain View"); user.setEncAddress(encAddress); + + user.initSimpleTypes(); + // save the user with spring assertFalse(userEncryptedRepository.existsById(user.getId())); - DateTime beforeDateTime = user.plainDateTime.plus(1).minus(1); - assertEquals(user.plainDateTime, beforeDateTime); - System.err.println("before: "+beforeDateTime); userEncryptedRepository.save(user); - DateTime afterDateTime = user.plainDateTime.plus(1).minus(1); - assertEquals(beforeDateTime, afterDateTime); - System.err.println("afterDateTime: "+afterDateTime); + // read the user with Spring + Optional writeSpringReadSpring = userEncryptedRepository.findById(user.getId()); + assertTrue(writeSpringReadSpring.isPresent()); + writeSpringReadSpring.ifPresent(u -> assertEquals(user, u)); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSpring_readSDK() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSpring_readSDK", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.initSimpleTypes(); + // save the user with spring + assertFalse(userEncryptedRepository.existsById(user.getId())); + userEncryptedRepository.save(user); + + // read user with SDK + UserEncrypted writeSpringReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAs(UserEncrypted.class); + writeSpringReadSDK.setId(user.getId()); + writeSpringReadSDK.setVersion(user.getVersion()); + assertEquals(user, writeSpringReadSDK); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSDK_readSpring() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSDK_readSpring", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.initSimpleTypes(); + + // save the user with the SDK + assertFalse(userEncryptedRepository.existsById(user.getId().toUpperCase())); + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), + user); + + Optional writeSDKReadSpring = userEncryptedRepository.findById(user.getId().toUpperCase()); + assertTrue(writeSDKReadSpring.isPresent()); + writeSDKReadSpring.get().setId(user.getId()); + writeSDKReadSpring.get().setVersion(user.getVersion()); + writeSDKReadSpring.ifPresent(u -> assertEquals(user, u)); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void writeSDK_readSDK() { + boolean cleanAfter = false; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "writeSDK_readSDK", "l", "hello"); + AddressWithEncStreet address = new AddressWithEncStreet(); // plaintext address with encrypted street + address.setEncStreet("Olcott Street"); + address.setCity("Santa Clara"); + user.addAddress(address); + user.setHomeAddress(null); + Address encAddress = new Address(); // encrypted address with plaintext street. + encAddress.setStreet("Castro St"); + encAddress.setCity("Mountain View"); + user.setEncAddress(encAddress); + + user.clazz = String.class; // not supported by SDK, but not support by UserEncrypted.toString() either. + user.initSimpleTypes(); + // write the user with the SDK + assertFalse(userEncryptedRepository.existsById(user.getId().toUpperCase())); + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), + user); + + // read the user with the SDK + UserEncrypted writeSDKReadSDK = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId().toUpperCase()).contentAs(UserEncrypted.class); + writeSDKReadSDK.setId(user.getId()); + writeSDKReadSDK.setVersion(user.getVersion()); + assertEquals(user.clazz, writeSDKReadSDK.clazz); + writeSDKReadSDK.clazz = null; // null these out as UserEncrypted.toString() doesn't support them. + user.clazz = null; + assertEquals(user, writeSDKReadSDK); + + if (cleanAfter) { + try { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } + } + + @Test + @IgnoreWhen(clusterTypes = ClusterType.MOCKED) + void testFromMigration() { + boolean cleanAfter = true; + UserEncrypted user = new UserEncrypted(UUID.randomUUID().toString(), "testFromMigration", "l", + "migrating from unencrypted"); + JsonObject jo = JsonObject.jo(); + jo.put("firstname", user.getFirstname()); + jo.put("lastname", user.getLastname()); + jo.put("encryptedField", user.encryptedField); + jo.put("_class", user._class); + + // save it unencrypted + clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId(), jo); + JsonObject migration = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAsObject(); + assertEquals("migrating from unencrypted", migration.get("encryptedField")); + assertNull(migration.get(CryptoManager.DEFAULT_ENCRYPTER_ALIAS + "encryptedField")); + + // it will be retrieved successfully Optional found = userEncryptedRepository.findById(user.getId()); assertTrue(found.isPresent()); - System.err.println("Found: "+found.get()); + user.setVersion(found.get().getVersion()); found.ifPresent(u -> assertEquals(user, u)); - assertTrue(userEncryptedRepository.existsById(user.getId())); + // save it encrypted + UserEncrypted saved = userEncryptedRepository.save(user); + // it will be retrieved successfully + Optional foundEnc = userEncryptedRepository.findById(user.getId()); + assertTrue(foundEnc.isPresent()); + user.setVersion(foundEnc.get().getVersion()); + foundEnc.ifPresent(u -> assertEquals(user, u)); - clientFactory.getCluster().bucket(config().bucketname()).defaultCollection().insert(user.getId().toUpperCase(), user); - UserEncrypted sdkUser = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() - .get(user.getId()).contentAs(UserEncrypted.class); - System.err.println("user: : " + user); - sdkUser.setId(user.getId()); - sdkUser.setVersion(user.getVersion()); - //assertTrue(user.encDateTime.equals( sdkUser.encDateTime)); - System.err.println("sdkUser : " + sdkUser); - assertEquals(user.plainDateTime, found.get().plainDateTime); - assertEquals(user.plainDateTime, sdkUser.plainDateTime); - assertEquals(user.encDateTime, found.get().encDateTime); - assertEquals(user.encDateTime, sdkUser.encDateTime); - assertEquals(user, sdkUser); - // userEncryptedRepository.delete(user); + // retrieve it without decrypting + JsonObject encrypted = clientFactory.getCluster().bucket(config().bucketname()).defaultCollection() + .get(user.getId()).contentAsObject(); + assertEquals("myKey", + ((JsonObject) encrypted.get(CryptoManager.DEFAULT_ENCRYPTED_FIELD_NAME_PREFIX + "encryptedField")).get("kid")); + assertNull(encrypted.get("encryptedField")); + + if (cleanAfter) { + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId()); + try { // may have also used upperCased-id + couchbaseTemplate.removeById(UserEncrypted.class).one(user.getId().toUpperCase()); + } catch (DataRetrievalFailureException iae) { + // ignore + } + } } @Configuration @@ -163,7 +344,7 @@ public String getBucketName() { } @Override - public ObjectMapper couchbaseObjectMapper(CryptoManager cryptoManager){ + public ObjectMapper couchbaseObjectMapper(CryptoManager cryptoManager) { ObjectMapper om = super.couchbaseObjectMapper(cryptoManager); om.registerModule(new JodaModule()); return om; @@ -181,53 +362,56 @@ protected void configureEnvironment(ClusterEnvironment.Builder builder) { @Override protected CryptoManager cryptoManager() { - - Decrypter decrypter = new Decrypter() { - @Override - public String algorithm() { - return "myAlg"; - } - - @Override - public byte[] decrypt(EncryptionResult encrypted) { - return Base64.getDecoder().decode(encrypted.getString("ciphertext")); - } - }; - - Encrypter encrypter = new Encrypter() { - @Override - public EncryptionResult encrypt(byte[] plaintext) { - return EncryptionResult - .fromMap(mapOf("alg", "myAlg", "ciphertext", Base64.getEncoder().encodeToString(plaintext))); - } - }; Map keyMap = new HashMap(); - keyMap.put("myKey", - new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, }); + keyMap.put("myKey", new byte[64] /* all zeroes */); Keyring keyring = Keyring.fromMap(keyMap); - // Provider secProvider; AeadAes256CbcHmacSha512Provider provider = AeadAes256CbcHmacSha512Provider.builder().keyring(keyring) /*.securityProvider(secProvider)*/.build(); - return DefaultCryptoManager.builder().decrypter(provider.decrypter()) - .defaultEncrypter(provider.encrypterForKey("myKey")).build(); + return new WrappingCryptoManager(DefaultCryptoManager.builder().decrypter(provider.decrypter()) + .defaultEncrypter(provider.encrypterForKey("myKey")).build()); } - byte[] hmacMe(String cbc_secret_key, String cbc_api_message) { - try { - return hmac("hmacSHA256", cbc_secret_key.getBytes("utf-8"), cbc_api_message.getBytes("utf-8")); - } catch (UnsupportedEncodingException | NoSuchAlgorithmException | InvalidKeyException ue) { - return null; + public class WrappingCryptoManager implements CryptoManager { + CryptoManager cryptoManager; + + public WrappingCryptoManager(CryptoManager cryptoManager) { + this.cryptoManager = cryptoManager; + } + + @Override + public Map encrypt(byte[] plaintext, String encrypterAlias) { + Map encryptedNode = cryptoManager.encrypt(plaintext, encrypterAlias); + return encryptedNode; } - } - static byte[] hmac(String algorithm, byte[] key, byte[] message) - throws NoSuchAlgorithmException, InvalidKeyException { - Mac mac = Mac.getInstance(algorithm); - mac.init(new SecretKeySpec(key, algorithm)); - return mac.doFinal(message); + @Override + public byte[] decrypt(Map encryptedNode) { + byte[] result = cryptoManager.decrypt(encryptedNode); + return result; + } + + private String toBytes(byte[] plaintext) { + StringBuffer sb = new StringBuffer(); + for (byte b : plaintext) { + sb.append(b); + sb.append(" "); + } + return sb.toString(); + } + + private boolean cmp(byte[] a, byte[] b) { + if (a.length != b.length) + return false; + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) + return false; + } + return true; + } + + byte[] canned_sdk_encbytes = { 34, 65, 81, 73, 68, 66, 65, 61, 61, 34 }; + byte[] canned_spring_encbytes = { 91, 49, 44, 50, 44, 51, 44, 52, 93 }; } } - }