├── docker-compose.yml ├── src ├── main │ ├── resources │ │ └── META-INF │ │ │ ├── spring │ │ │ └── org.springframework.boot.autoconfigure.AutoConfiguration.imports │ │ │ └── spring.factories │ └── java │ │ └── com │ │ └── bol │ │ ├── secure │ │ ├── FieldEncryptedPredicate.java │ │ ├── Encrypted.java │ │ ├── AbstractEncryptionEventListener.java │ │ ├── CachedEncryptionEventListener.java │ │ └── ReflectionEncryptionEventListener.java │ │ ├── crypt │ │ ├── DocumentCryptException.java │ │ └── FieldCryptException.java │ │ ├── reflection │ │ ├── Node.java │ │ └── ReflectionCache.java │ │ └── config │ │ └── EncryptAutoConfiguration.java └── test │ ├── java │ └── com │ │ └── bol │ │ └── system │ │ ├── polymorphism │ │ ├── model │ │ │ ├── AbstractSubObject.java │ │ │ ├── SubObject.java │ │ │ └── TestObject.java │ │ └── PolymorphismSystemTest.java │ │ ├── model │ │ ├── Ssn.java │ │ ├── Identifier.java │ │ ├── Person.java │ │ ├── MySubBeanNotEncrypted.java │ │ ├── RenamedField.java │ │ ├── MySubBean.java │ │ ├── PlainBean.java │ │ ├── InitBean.java │ │ ├── PrimitiveField.java │ │ └── MyBean.java │ │ ├── reflection │ │ ├── ReflectionEncryptSystemTest.java │ │ └── ReflectionMongoDBConfiguration.java │ │ ├── field │ │ ├── FieldDetectionMongoDBConfiguration.java │ │ └── FieldDetectionEncryptSystemTest.java │ │ ├── cached │ │ ├── CachedEncryptSystemTest.java │ │ └── CachedMongoDBConfiguration.java │ │ ├── autoconfig │ │ ├── EncryptionNotConfiguredTest.java │ │ ├── EncryptionConfiguredFullTest.java │ │ ├── EncryptionConfiguredShortTest.java │ │ └── EncryptionWithCustomAbstractEventListenerTest.java │ │ ├── CryptAssert.java │ │ ├── MongoDBConfiguration.java │ │ └── EncryptSystemTest.java │ └── resources │ ├── application-autoconfig-short.yml │ └── application-autoconfig-full.yml ├── .gitignore ├── .github └── workflows │ └── maven.yml ├── pom.xml ├── LICENSE.txt └── README.md /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | mongo: 5 | image: mongo 6 | ports: 7 | - 27017:27017 8 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports: -------------------------------------------------------------------------------- 1 | com.bol.config.EncryptAutoConfiguration 2 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/spring.factories: -------------------------------------------------------------------------------- 1 | org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.bol.config.EncryptAutoConfiguration 2 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/polymorphism/model/AbstractSubObject.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.polymorphism.model; 2 | 3 | public class AbstractSubObject { 4 | } 5 | -------------------------------------------------------------------------------- /src/test/resources/application-autoconfig-short.yml: -------------------------------------------------------------------------------- 1 | mongodb.encrypt: 2 | keys: 3 | - version: 1 4 | key: hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA= 5 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/Ssn.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | 5 | public class Ssn extends Identifier { 6 | @Encrypted 7 | public String ssn; 8 | } 9 | -------------------------------------------------------------------------------- /src/test/resources/application-autoconfig-full.yml: -------------------------------------------------------------------------------- 1 | mongodb.encrypt: 2 | default-key: 1 3 | type: reflection 4 | silent-decryption-failures: true 5 | keys: 6 | - version: 1 7 | key: hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA= 8 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/Identifier.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | 5 | public class Identifier { 6 | @Encrypted 7 | public String someSecret; 8 | public String notSecret; 9 | } 10 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/polymorphism/model/SubObject.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.polymorphism.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | import org.springframework.data.mongodb.core.mapping.Field; 5 | 6 | public class SubObject extends AbstractSubObject { 7 | @Field 8 | @Encrypted 9 | public String field; 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/com/bol/secure/FieldEncryptedPredicate.java: -------------------------------------------------------------------------------- 1 | package com.bol.secure; 2 | 3 | import java.lang.reflect.Field; 4 | import java.util.function.Predicate; 5 | 6 | public interface FieldEncryptedPredicate extends Predicate { 7 | 8 | FieldEncryptedPredicate ANNOTATION_PRESENT = field -> field.isAnnotationPresent(Encrypted.class); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/Person.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import org.springframework.data.annotation.Id; 4 | import org.springframework.data.mongodb.core.mapping.Document; 5 | 6 | @Document 7 | public class Person { 8 | public static final String MONGO_PERSON = "person"; 9 | 10 | @Id 11 | public String id; 12 | public Ssn ssn; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/com/bol/secure/Encrypted.java: -------------------------------------------------------------------------------- 1 | package com.bol.secure; 2 | 3 | import java.lang.annotation.ElementType; 4 | import java.lang.annotation.Retention; 5 | import java.lang.annotation.RetentionPolicy; 6 | import java.lang.annotation.Target; 7 | 8 | @Retention(RetentionPolicy.RUNTIME) 9 | @Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) 10 | public @interface Encrypted { 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntelliJ 2 | *.iml 3 | *.ipr 4 | *.iws 5 | .idea 6 | classes 7 | 8 | # Eclipse 9 | .classpath 10 | .project 11 | .buildpath 12 | .springBeans 13 | .settings/ 14 | .metadata/ 15 | 16 | # Netbeans 17 | nb-configuration.xml 18 | 19 | # Maven 20 | target 21 | pom.xml.versionsBackup 22 | 23 | # Gradle 24 | build 25 | .gradle 26 | 27 | # OS 28 | Thumbs.db 29 | .DS_Store 30 | 31 | # misc 32 | .checkstyle 33 | .pmd 34 | .fbprefs 35 | MANIFEST.MF 36 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/reflection/ReflectionEncryptSystemTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.reflection; 2 | 3 | import com.bol.system.EncryptSystemTest; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | 8 | @RunWith(SpringRunner.class) 9 | @SpringBootTest(classes = {ReflectionMongoDBConfiguration.class}) 10 | public class ReflectionEncryptSystemTest extends EncryptSystemTest { 11 | } 12 | -------------------------------------------------------------------------------- /src/main/java/com/bol/crypt/DocumentCryptException.java: -------------------------------------------------------------------------------- 1 | package com.bol.crypt; 2 | 3 | public class DocumentCryptException extends RuntimeException { 4 | Object id; 5 | String collectionName; 6 | 7 | public DocumentCryptException(String collectionName, Object id, Throwable e) { 8 | super("Collection: " + collectionName + ", Id: " + id, e); 9 | this.id = id; 10 | this.collectionName = collectionName; 11 | } 12 | 13 | public Object getId() { 14 | return id; 15 | } 16 | 17 | public String getCollectionName() { 18 | return collectionName; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/reflection/ReflectionMongoDBConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.reflection; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.secure.ReflectionEncryptionEventListener; 5 | import com.bol.system.MongoDBConfiguration; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | @Configuration 10 | public class ReflectionMongoDBConfiguration extends MongoDBConfiguration { 11 | @Bean 12 | public ReflectionEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 13 | return new ReflectionEncryptionEventListener(cryptVault); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/MySubBeanNotEncrypted.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import org.springframework.data.mongodb.core.mapping.Field; 4 | 5 | public class MySubBeanNotEncrypted { 6 | public static final String MONGO_NONSENSITIVEDATA1 = "nonSensitiveData1"; 7 | public static final String MONGO_NONSENSITIVEDATA2 = "nonSensitiveData2"; 8 | 9 | @Field 10 | public String nonSensitiveData1; 11 | 12 | @Field 13 | public String nonSensitiveData2; 14 | 15 | public MySubBeanNotEncrypted() { 16 | } 17 | 18 | public MySubBeanNotEncrypted(String nonSensitiveData1, String nonSensitiveData2) { 19 | this.nonSensitiveData1 = nonSensitiveData1; 20 | this.nonSensitiveData2 = nonSensitiveData2; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/RenamedField.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | import org.springframework.data.mongodb.core.mapping.Document; 5 | import org.springframework.data.mongodb.core.mapping.Field; 6 | 7 | import static com.bol.system.model.RenamedField.MONGO_RENAMEDFIELD; 8 | 9 | @Document(collection = MONGO_RENAMEDFIELD) 10 | public class RenamedField { 11 | public static final String MONGO_RENAMEDFIELD = "renamedfield"; 12 | public static final String MONGO_SOMESECRET = "someSecret"; 13 | public static final String MONGO_NOTSECRET = "notSecret"; 14 | public static final String MONGO_PASSWORD = "password"; 15 | 16 | @Encrypted 17 | @Field("password") 18 | public String someSecret; 19 | public String notSecret; 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-maven 3 | 4 | name: Build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Start MongoDB 21 | uses: supercharge/mongodb-github-action@1.10.0 22 | with: 23 | mongodb-version: '4.4' 24 | 25 | - name: Set up JDK 26 | uses: actions/setup-java@v2 27 | with: 28 | java-version: '17' 29 | distribution: 'temurin' 30 | 31 | - name: Build with Maven 32 | run: mvn -B package --file pom.xml 33 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/polymorphism/model/TestObject.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.polymorphism.model; 2 | 3 | import org.springframework.data.annotation.Id; 4 | import org.springframework.data.mongodb.core.mapping.Document; 5 | import org.springframework.data.mongodb.core.mapping.Field; 6 | 7 | import java.util.List; 8 | 9 | /** 10 | * 11 | * > db.testObject.find().pretty(); 12 | * { 13 | * "_id" : ObjectId("5afaf0941a547741cd41fb5d"), 14 | * "_class" : "com.bol.system.reflection.EncryptSystemTest$TestObject", 15 | * "list" : [ 16 | * { 17 | * "field" : "this is a test", 18 | * "_class" : "com.bol.system.reflection.EncryptSystemTest$SubObject" 19 | * } 20 | * ] 21 | * } 22 | * */ 23 | @Document 24 | public class TestObject { 25 | public static final String MONGO_TESTOBJECT = "testObject"; 26 | 27 | @Id 28 | public String id; 29 | 30 | @Field 31 | public List list; 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/bol/crypt/FieldCryptException.java: -------------------------------------------------------------------------------- 1 | package com.bol.crypt; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | /** collect the whole tree in a single exception class for simplicity */ 7 | public class FieldCryptException extends RuntimeException { 8 | List fields = new ArrayList<>(); 9 | 10 | public FieldCryptException(String fieldName, Throwable e) { 11 | super(e); 12 | fields.add(fieldName); 13 | } 14 | 15 | public FieldCryptException chain(String fieldName) { 16 | if (fieldName != null && fieldName.length() > 0) fields.add(fieldName); 17 | return this; 18 | } 19 | 20 | @Override 21 | public String getMessage() { 22 | StringBuilder result = new StringBuilder(); 23 | for (int i = fields.size() - 1; i >= 0; i--) { 24 | result.append(fields.get(i)).append('.'); 25 | } 26 | return result.substring(0, result.length()-1); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/MySubBean.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | import org.springframework.data.mongodb.core.mapping.Field; 5 | 6 | import java.util.List; 7 | import java.util.Map; 8 | import java.util.Set; 9 | 10 | public class MySubBean { 11 | public static final String MONGO_NONSENSITIVEDATA = "nonSensitiveData"; 12 | public static final String MONGO_SECRETSTRING = "secretString"; 13 | 14 | @Field 15 | public String nonSensitiveData; 16 | 17 | @Field 18 | @Encrypted 19 | public String secretString; 20 | 21 | @Field 22 | /** this would cause infinite recursion in reflectioncache */ 23 | public MyBean recursiveBean; 24 | 25 | @Field 26 | public Set>>>> nestedCollectionsBean; 27 | 28 | public MySubBean() {} 29 | 30 | public MySubBean(String nonSensitiveData, String secretString) { 31 | this.nonSensitiveData = nonSensitiveData; 32 | this.secretString = secretString; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/field/FieldDetectionMongoDBConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.field; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.secure.ReflectionEncryptionEventListener; 5 | import com.bol.system.MongoDBConfiguration; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | 9 | import java.util.Arrays; 10 | import java.util.HashSet; 11 | import java.util.Set; 12 | 13 | @Configuration 14 | public class FieldDetectionMongoDBConfiguration extends MongoDBConfiguration { 15 | 16 | private final Set fields = new HashSet<>(Arrays.asList("PlainBean.sensitiveData", "PlainSubBean.sensitiveData")); 17 | 18 | @Bean 19 | public ReflectionEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 20 | return new ReflectionEncryptionEventListener(cryptVault, field -> { 21 | String fieldName = field.getDeclaringClass().getSimpleName() + "." + field.getName(); 22 | return fields.contains(fieldName); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/cached/CachedEncryptSystemTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.cached; 2 | 3 | import com.bol.system.EncryptSystemTest; 4 | import com.bol.system.model.MyBean; 5 | import org.bson.Document; 6 | import org.bson.types.ObjectId; 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.springframework.boot.test.context.SpringBootTest; 10 | import org.springframework.test.context.junit4.SpringRunner; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | 14 | @RunWith(SpringRunner.class) 15 | @SpringBootTest(classes = {CachedMongoDBConfiguration.class}) 16 | public class CachedEncryptSystemTest extends EncryptSystemTest { 17 | 18 | @Test 19 | public void checkIfClassFieldIsAbsent() { 20 | MyBean bean = new MyBean(); 21 | mongoTemplate.save(bean); 22 | 23 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new Document("_id", new ObjectId(bean.id))).first(); 24 | 25 | // there should be no `_class` in there 26 | assertThat(fromMongo).containsOnlyKeys("_id", "version"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/autoconfig/EncryptionNotConfiguredTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.autoconfig; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.secure.AbstractEncryptionEventListener; 5 | import com.bol.secure.CachedEncryptionEventListener; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.test.context.junit4.SpringRunner; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @RunWith(SpringRunner.class) 16 | @EnableAutoConfiguration 17 | @SpringBootTest(classes = EncryptionNotConfiguredTest.class) 18 | public class EncryptionNotConfiguredTest { 19 | 20 | @Autowired(required = false) CryptVault cryptVault; 21 | @Autowired(required = false) AbstractEncryptionEventListener eventListener; 22 | 23 | @Test 24 | public void sanityTest() { 25 | assertThat(cryptVault).isNull(); 26 | assertThat(eventListener).isNull(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/PlainBean.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import org.springframework.data.annotation.Id; 4 | import org.springframework.data.mongodb.core.mapping.Document; 5 | import org.springframework.data.mongodb.core.mapping.Field; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import static com.bol.system.model.PlainBean.MONGO_PLAINBEAN; 11 | 12 | @Document(collection = MONGO_PLAINBEAN) 13 | public class PlainBean { 14 | public static final String MONGO_PLAINBEAN = "plainBean"; 15 | 16 | @Id public String id; 17 | 18 | @Field public String nonSensitiveData; 19 | @Field public String sensitiveData; 20 | 21 | public PlainSubBean singleSubBean; 22 | public List subBeans = new ArrayList<>(); 23 | 24 | public static class PlainSubBean { 25 | @Field public String nonSensitiveData; 26 | @Field public String sensitiveData; 27 | 28 | public PlainSubBean() {} 29 | public PlainSubBean(String nonSensitiveData, String sensitiveData) { 30 | this.nonSensitiveData = nonSensitiveData; 31 | this.sensitiveData = sensitiveData; 32 | } 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/InitBean.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.mongodb.core.mapping.Document; 6 | 7 | import java.util.ArrayList; 8 | import java.util.List; 9 | 10 | import static com.bol.system.model.InitBean.MONGO_INITBEAN; 11 | 12 | @Document(collection = MONGO_INITBEAN) 13 | public class InitBean { 14 | public static final String MONGO_INITBEAN = "initbean"; 15 | public static final String MONGO_SUB_BEANS = "subBeans"; 16 | public static final String MONGO_DATA1 = "data1"; 17 | public static final String MONGO_DATA2 = "data2"; 18 | 19 | @Id 20 | public String id; 21 | @Encrypted 22 | public String data1; 23 | public List subBeans = new ArrayList<>(); 24 | 25 | // this also tests non-public subdocuments 26 | public void addSubBean(String input) { 27 | InitSubBean initSubBean = new InitSubBean(); 28 | initSubBean.data2 = input; 29 | subBeans.add(initSubBean); 30 | } 31 | } 32 | 33 | class InitSubBean { 34 | @Encrypted 35 | public String data2; 36 | } 37 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/PrimitiveField.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.mongodb.core.mapping.Document; 6 | import org.springframework.data.mongodb.core.mapping.Field; 7 | 8 | import java.util.UUID; 9 | 10 | import static com.bol.system.model.PrimitiveField.MONGO_PRIMITIVEFIELD; 11 | 12 | /* 13 | > db.primitivefield.find().pretty(); 14 | { 15 | "_id" : BinData(3,"m0jcj60kTHxdZZriPCPxuw=="), 16 | "data" : BinData(0,"gIUv9oVQRNFDcialLXqdd/MiSrrkuSmOLmFr1M+x5hBk"), 17 | "primitiveInt" : 1, 18 | "encryptedPrimitiveInt" : BinData(0,"gCIePEAEVzZ8ymqz30WeSVCqkq3sLtk0Pc+6rjgMDaoO"), 19 | "_class" : "com.bol.system.model.PrimitiveField" 20 | } 21 | */ 22 | @Document(collection = MONGO_PRIMITIVEFIELD) 23 | public class PrimitiveField { 24 | public static final String MONGO_PRIMITIVEFIELD = "primitivefield"; 25 | 26 | // try using UUID as ID 27 | @Id 28 | public UUID id; 29 | 30 | @Field 31 | @Encrypted 32 | public byte[] data; 33 | 34 | @Field 35 | public int primitiveInt; 36 | 37 | @Field 38 | @Encrypted 39 | public int encryptedPrimitiveInt; 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/autoconfig/EncryptionConfiguredFullTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.autoconfig; 2 | 3 | import com.bol.config.EncryptAutoConfiguration; 4 | import com.bol.crypt.CryptVault; 5 | import com.bol.secure.AbstractEncryptionEventListener; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.test.context.ActiveProfiles; 12 | import org.springframework.test.context.junit4.SpringRunner; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | @ActiveProfiles("autoconfig-full") 17 | @RunWith(SpringRunner.class) 18 | @EnableAutoConfiguration 19 | @SpringBootTest(classes = {EncryptionConfiguredFullTest.class, EncryptAutoConfiguration.class}) 20 | public class EncryptionConfiguredFullTest { 21 | 22 | @Autowired(required = false) CryptVault cryptVault; 23 | @Autowired(required = false) AbstractEncryptionEventListener eventListener; 24 | 25 | @Test 26 | public void sanityTest() { 27 | assertThat(cryptVault).isNotNull(); 28 | assertThat(eventListener).isNotNull(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/autoconfig/EncryptionConfiguredShortTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.autoconfig; 2 | 3 | import com.bol.config.EncryptAutoConfiguration; 4 | import com.bol.crypt.CryptVault; 5 | import com.bol.secure.AbstractEncryptionEventListener; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.test.context.ActiveProfiles; 12 | import org.springframework.test.context.junit4.SpringRunner; 13 | 14 | import static org.assertj.core.api.Assertions.assertThat; 15 | 16 | @ActiveProfiles("autoconfig-short") 17 | @RunWith(SpringRunner.class) 18 | @EnableAutoConfiguration 19 | @SpringBootTest(classes = {EncryptionConfiguredShortTest.class, EncryptAutoConfiguration.class}) 20 | public class EncryptionConfiguredShortTest { 21 | 22 | @Autowired(required = false) CryptVault cryptVault; 23 | @Autowired(required = false) AbstractEncryptionEventListener eventListener; 24 | 25 | @Test 26 | public void sanityTest() { 27 | assertThat(cryptVault).isNotNull(); 28 | assertThat(eventListener).isNotNull(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/CryptAssert.java: -------------------------------------------------------------------------------- 1 | package com.bol.system; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import org.bson.types.Binary; 5 | 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | public class CryptAssert { 9 | 10 | private final CryptVault cryptVault; 11 | 12 | public CryptAssert(CryptVault cryptVault) { 13 | this.cryptVault = cryptVault; 14 | } 15 | 16 | /** 17 | * simplistic mongodb BSON serialization lengths: 18 | * - 10 bytes for wrapping BSONObject prefix 19 | * - 1 byte prefix before field name 20 | * - field name (1 byte/char) 21 | * - 1 byte 0-terminator after field name 22 | * - 4 byte prefix before field value 23 | * - field value (1byte/char) 24 | * - 1 byte 0-terminator after field value 25 | * - 2 bytes 0 terminator for wrapping BSONObject 26 | *

27 | * (e.g. for a single primitive string, 12 extra bytes are added above its own length) 28 | */ 29 | public void assertCryptLength(Object cryptedSecretBinary, int serializedLength) { 30 | assertThat(cryptedSecretBinary).isInstanceOf(Binary.class); 31 | 32 | Object cryptedSecretBytes = ((Binary) cryptedSecretBinary).getData(); 33 | 34 | assertThat(cryptedSecretBytes).isInstanceOf(byte[].class); 35 | byte[] cryptedBytes = (byte[]) cryptedSecretBytes; 36 | 37 | int expectedCryptedLength = cryptVault.expectedCryptedLength(serializedLength); 38 | assertThat(cryptedBytes.length).isEqualTo(expectedCryptedLength); 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/cached/CachedMongoDBConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.cached; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.secure.CachedEncryptionEventListener; 5 | import com.bol.system.MongoDBConfiguration; 6 | import org.springframework.context.annotation.Bean; 7 | import org.springframework.context.annotation.Configuration; 8 | import org.springframework.data.mongodb.MongoDatabaseFactory; 9 | import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper; 10 | import org.springframework.data.mongodb.core.convert.MappingMongoConverter; 11 | import org.springframework.data.mongodb.core.convert.MongoCustomConversions; 12 | import org.springframework.data.mongodb.core.mapping.MongoMappingContext; 13 | 14 | @Configuration 15 | public class CachedMongoDBConfiguration extends MongoDBConfiguration { 16 | @Bean 17 | public CachedEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 18 | return new CachedEncryptionEventListener(cryptVault); 19 | } 20 | 21 | @Override 22 | @Bean 23 | public MappingMongoConverter mappingMongoConverter(MongoDatabaseFactory databaseFactory, MongoCustomConversions customConversions, MongoMappingContext mappingContext) { 24 | MappingMongoConverter converter = super.mappingMongoConverter(databaseFactory, customConversions, mappingContext); 25 | // NB: without overriding defaultMongoTypeMapper, an _class field is put in every document 26 | // since we know exactly which java class a specific document maps to, this is surplus 27 | converter.setTypeMapper(new DefaultMongoTypeMapper(null)); 28 | return converter; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/java/com/bol/reflection/Node.java: -------------------------------------------------------------------------------- 1 | package com.bol.reflection; 2 | 3 | import java.lang.reflect.Field; 4 | import java.util.List; 5 | 6 | public class Node { 7 | public final String fieldName; 8 | public final String documentName; 9 | public final List children; 10 | public final Type type; 11 | public final Field field; 12 | 13 | public Node(String fieldName, List children, Type type) { 14 | this.fieldName = fieldName; 15 | this.documentName = fieldName; 16 | this.children = children; 17 | this.type = type; 18 | this.field = null; 19 | } 20 | 21 | public Node(String fieldName, String documentName, List children, Type type) { 22 | this.fieldName = fieldName; 23 | this.documentName = documentName; 24 | this.children = children; 25 | this.type = type; 26 | this.field = null; 27 | } 28 | 29 | public Node(String fieldName, String documentName, List children, Type type, Field field) { 30 | this.fieldName = fieldName; 31 | this.documentName = documentName; 32 | this.children = children; 33 | this.type = type; 34 | this.field = field; 35 | } 36 | 37 | public enum Type { 38 | /** field with @Encrypted annotation present - to be crypted directly */ 39 | DIRECT, 40 | /** field is a BasicDBList, descend */ 41 | LIST, 42 | /** field is a Map, need to descend on its values */ 43 | MAP, 44 | /** field is a sub-document, descend */ 45 | DOCUMENT 46 | } 47 | 48 | @Override 49 | public String toString() { 50 | return "Node{" + 51 | "fieldName='" + fieldName + '\'' + 52 | ", documentName='" + documentName + '\'' + 53 | ", children=" + children + 54 | ", type=" + type + 55 | ", field=" + field + 56 | '}'; 57 | } 58 | 59 | public static final Node EMPTY = new Node(null, null, null); 60 | } 61 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/MongoDBConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.bol.system; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.mongodb.ConnectionString; 5 | import com.mongodb.MongoClientSettings; 6 | import com.mongodb.client.MongoClient; 7 | import com.mongodb.client.MongoClients; 8 | import org.bson.UuidRepresentation; 9 | import org.springframework.beans.factory.annotation.Value; 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 11 | import org.springframework.context.annotation.Bean; 12 | import org.springframework.context.annotation.Configuration; 13 | import org.springframework.data.mongodb.MongoDatabaseFactory; 14 | import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; 15 | import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper; 16 | import org.springframework.data.mongodb.core.convert.MappingMongoConverter; 17 | import org.springframework.data.mongodb.core.convert.MongoCustomConversions; 18 | import org.springframework.data.mongodb.core.mapping.MongoMappingContext; 19 | 20 | import java.util.Base64; 21 | 22 | @Configuration 23 | public abstract class MongoDBConfiguration extends AbstractMongoClientConfiguration { 24 | 25 | private static final byte[] secretKey = Base64.getDecoder().decode("hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA="); 26 | 27 | @Value("${mongodb.port:27017}") 28 | int port; 29 | 30 | @Override 31 | protected String getDatabaseName() { 32 | return "test"; 33 | } 34 | 35 | @Override 36 | public MongoClient mongoClient() { 37 | return MongoClients.create(MongoClientSettings.builder() 38 | .uuidRepresentation(UuidRepresentation.STANDARD) 39 | .applyConnectionString(new ConnectionString("mongodb://localhost:" + port)) 40 | .build() 41 | ); 42 | } 43 | 44 | @Bean 45 | public CryptVault cryptVault() { 46 | return new CryptVault() 47 | .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, secretKey) 48 | .withDefaultKeyVersion(0); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/autoconfig/EncryptionWithCustomAbstractEventListenerTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.autoconfig; 2 | 3 | import com.bol.config.EncryptAutoConfiguration; 4 | import com.bol.crypt.CryptVault; 5 | import com.bol.secure.AbstractEncryptionEventListener; 6 | import com.bol.secure.CachedEncryptionEventListener; 7 | import com.bol.secure.ReflectionEncryptionEventListener; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.springframework.beans.factory.annotation.Autowired; 11 | import org.springframework.boot.autoconfigure.AutoConfigureAfter; 12 | import org.springframework.boot.autoconfigure.EnableAutoConfiguration; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.boot.test.context.TestConfiguration; 15 | import org.springframework.context.annotation.Bean; 16 | import org.springframework.test.context.ActiveProfiles; 17 | import org.springframework.test.context.junit4.SpringRunner; 18 | 19 | import static org.assertj.core.api.Assertions.assertThat; 20 | 21 | @ActiveProfiles("autoconfig-short") 22 | @RunWith(SpringRunner.class) 23 | @EnableAutoConfiguration 24 | @SpringBootTest(classes = {EncryptionWithCustomAbstractEventListenerTest.class, EncryptionWithCustomAbstractEventListenerTest.AbstractEventListenerConfig.class, EncryptAutoConfiguration.class}) 25 | public class EncryptionWithCustomAbstractEventListenerTest { 26 | 27 | @Autowired(required = false) CryptVault cryptVault; 28 | @Autowired(required = false) AbstractEncryptionEventListener eventListener; 29 | 30 | @Test 31 | public void sanityTest() { 32 | assertThat(cryptVault).isNotNull(); 33 | assertThat(eventListener).isNotNull(); 34 | assertThat(eventListener).isInstanceOf(ReflectionEncryptionEventListener.class); 35 | } 36 | 37 | 38 | @TestConfiguration 39 | static class AbstractEventListenerConfig { 40 | @Bean 41 | AbstractEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 42 | return new ReflectionEncryptionEventListener(cryptVault); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/polymorphism/PolymorphismSystemTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.polymorphism; 2 | 3 | import com.bol.system.model.Person; 4 | import com.bol.system.polymorphism.model.SubObject; 5 | import com.bol.system.polymorphism.model.TestObject; 6 | import com.bol.system.reflection.ReflectionMongoDBConfiguration; 7 | import org.bson.Document; 8 | import org.bson.types.Binary; 9 | import org.bson.types.ObjectId; 10 | import org.junit.Before; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.springframework.beans.factory.annotation.Autowired; 14 | import org.springframework.boot.test.context.SpringBootTest; 15 | import org.springframework.data.mongodb.core.MongoTemplate; 16 | import org.springframework.test.context.junit4.SpringRunner; 17 | 18 | import java.util.ArrayList; 19 | import java.util.Collections; 20 | 21 | import static org.assertj.core.api.Assertions.assertThat; 22 | import static org.springframework.data.mongodb.core.query.Criteria.where; 23 | import static org.springframework.data.mongodb.core.query.Query.query; 24 | 25 | @RunWith(SpringRunner.class) 26 | @SpringBootTest(classes = {ReflectionMongoDBConfiguration.class}) 27 | public class PolymorphismSystemTest { 28 | 29 | @Autowired MongoTemplate mongoTemplate; 30 | 31 | @Before 32 | public void cleanDb() { 33 | mongoTemplate.dropCollection(TestObject.class); 34 | mongoTemplate.dropCollection(Person.class); 35 | } 36 | 37 | @Test 38 | public void checkReflectiveEncryption() { 39 | TestObject testObject = new TestObject(); 40 | SubObject subObject = new SubObject(); 41 | subObject.field = "this is a test"; 42 | testObject.list = Collections.singletonList(subObject); 43 | 44 | mongoTemplate.save(testObject); 45 | 46 | TestObject fromDb = mongoTemplate.findOne(query(where("_id").is(testObject.id)), TestObject.class); 47 | 48 | assertThat(fromDb.list).hasSize(1); 49 | assertThat(((SubObject) fromDb.list.get(0)).field).isEqualTo(subObject.field); 50 | 51 | Document fromMongo = mongoTemplate.getCollection(TestObject.MONGO_TESTOBJECT).find(new Document("_id", new ObjectId(testObject.id))).first(); 52 | 53 | ArrayList dbNestedList = (ArrayList) fromMongo.get("list"); 54 | Document dbBean = (Document) dbNestedList.get(0); 55 | Object encryptedField = dbBean.get("field"); 56 | assertThat(encryptedField).isInstanceOf(Binary.class); 57 | Object encryptedFieldData = ((Binary) encryptedField).getData(); 58 | assertThat(encryptedFieldData).isInstanceOf(byte[].class); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/com/bol/config/EncryptAutoConfiguration.java: -------------------------------------------------------------------------------- 1 | package com.bol.config; 2 | 3 | import com.bol.config.CryptVaultAutoConfiguration.CryptVaultConfigurationProperties; 4 | import com.bol.crypt.CryptVault; 5 | import com.bol.secure.AbstractEncryptionEventListener; 6 | import com.bol.secure.CachedEncryptionEventListener; 7 | import com.bol.secure.ReflectionEncryptionEventListener; 8 | import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; 9 | import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; 10 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 11 | import org.springframework.boot.context.properties.ConfigurationProperties; 12 | import org.springframework.context.annotation.Bean; 13 | import org.springframework.context.annotation.Configuration; 14 | import org.springframework.stereotype.Component; 15 | 16 | @Configuration 17 | public class EncryptAutoConfiguration { 18 | 19 | @Bean 20 | /** This allows user to create and configure their own CryptVault, or rely on a global CryptVault from the standalone `cryptvault` library; 21 | * This CryptVault config is merely a convenience fallback, and also offers backwards compatibility with version 2.6.2 and below */ 22 | @ConditionalOnMissingBean(CryptVault.class) 23 | @ConditionalOnProperty(prefix = "mongodb.encrypt", name = "keys[0].key") 24 | CryptVault cryptVault(EncryptConfigurationProperties properties) { 25 | return new CryptVaultAutoConfiguration().cryptVault(properties); 26 | } 27 | 28 | @Bean 29 | @ConditionalOnMissingBean({AbstractEncryptionEventListener.class}) 30 | @ConditionalOnBean(CryptVault.class) 31 | AbstractEncryptionEventListener encryptionEventListener(CryptVault cryptVault, EncryptConfigurationProperties properties) { 32 | AbstractEncryptionEventListener eventListener; 33 | if ("reflection".equalsIgnoreCase(properties.type)) { 34 | eventListener = new ReflectionEncryptionEventListener(cryptVault); 35 | } else { 36 | eventListener = new CachedEncryptionEventListener(cryptVault); 37 | } 38 | 39 | if (properties.silentDecryptionFailures == Boolean.TRUE) eventListener.withSilentDecryptionFailure(true); 40 | 41 | return eventListener; 42 | } 43 | 44 | @Component 45 | @ConfigurationProperties("mongodb.encrypt") 46 | public static class EncryptConfigurationProperties extends CryptVaultConfigurationProperties { 47 | String type; 48 | Boolean silentDecryptionFailures; 49 | 50 | public void setType(String type) { 51 | this.type = type; 52 | } 53 | 54 | public void setSilentDecryptionFailures(Boolean silentDecryptionFailures) { 55 | this.silentDecryptionFailures = silentDecryptionFailures; 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/com/bol/secure/AbstractEncryptionEventListener.java: -------------------------------------------------------------------------------- 1 | package com.bol.secure; 2 | 3 | import com.bol.crypt.CryptOperationException; 4 | import com.bol.crypt.CryptVault; 5 | import com.mongodb.BasicDBList; 6 | import com.mongodb.BasicDBObject; 7 | import org.bson.*; 8 | import org.bson.types.Binary; 9 | import org.springframework.data.mongodb.core.mapping.event.AbstractMongoEventListener; 10 | 11 | import java.util.function.Function; 12 | 13 | public class AbstractEncryptionEventListener extends AbstractMongoEventListener { 14 | protected CryptVault cryptVault; 15 | private boolean silentDecryptionFailure = false; 16 | 17 | public AbstractEncryptionEventListener(CryptVault cryptVault) { 18 | this.cryptVault = cryptVault; 19 | } 20 | 21 | public T withSilentDecryptionFailure(boolean silentDecryptionFailure) { 22 | this.silentDecryptionFailure = silentDecryptionFailure; 23 | return (T) this; 24 | } 25 | 26 | class Decoder extends BasicBSONDecoder implements Function { 27 | public Object apply(Object o) { 28 | byte[] data; 29 | 30 | if (o instanceof Binary) data = ((Binary) o).getData(); 31 | else if (o instanceof byte[]) data = (byte[]) o; 32 | else if (!silentDecryptionFailure) throw new IllegalStateException("Got " + o.getClass() + ", expected: Binary or byte[]"); 33 | else return o; // e.g. crypted field not encrypted, other issues - we do our best 34 | 35 | try { 36 | byte[] serialized = cryptVault.decrypt((data)); 37 | BSONCallback bsonCallback = new BasicDBObjectCallback(); 38 | decode(serialized, bsonCallback); 39 | BSONObject deserialized = (BSONObject) bsonCallback.get(); 40 | return deserialized.get(""); 41 | } catch (CryptOperationException e) { 42 | if (silentDecryptionFailure) return null; 43 | throw e; 44 | } 45 | } 46 | } 47 | 48 | /** 49 | * BasicBSONEncoder returns BasicBSONObject which makes mongotemplate converter choke :( 50 | */ 51 | class BasicDBObjectCallback extends BasicBSONCallback { 52 | @Override 53 | public BSONObject create() { 54 | return new BasicDBObject(); 55 | } 56 | 57 | @Override 58 | protected BSONObject createList() { 59 | return new BasicDBList(); 60 | } 61 | 62 | @Override 63 | public BSONCallback createBSONCallback() { 64 | return new BasicDBObjectCallback(); 65 | } 66 | } 67 | 68 | class Encoder extends BasicBSONEncoder implements Function { 69 | public Object apply(Object o) { 70 | // we need to put even BSONObject and BSONList in a wrapping object before serialization, otherwise the type information is not encoded. 71 | // this is not particularly effective, however, it is the same that mongo driver itself uses on the wire, so it has 100% compatibility w.r.t de/serialization 72 | byte[] serialized = encode(new BasicBSONObject("", o)); 73 | return new Binary(cryptVault.encrypt(serialized)); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/model/MyBean.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.model; 2 | 3 | import com.bol.secure.Encrypted; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.annotation.Version; 6 | import org.springframework.data.mongodb.core.mapping.Document; 7 | import org.springframework.data.mongodb.core.mapping.Field; 8 | 9 | import java.util.List; 10 | import java.util.Map; 11 | import java.util.Set; 12 | 13 | @Document(collection = MyBean.MONGO_MYBEAN) 14 | public class MyBean { 15 | public static final String MONGO_MYBEAN = "mybean"; 16 | public static final String MONGO_NONSENSITIVEDATA = "nonSensitiveData"; 17 | public static final String MONGO_NONSENSITIVESUBBEAN = "nonSensitiveSubBean"; 18 | public static final String MONGO_NONSENSITIVESUBBEANLIST = "nonSensitiveSubBeanList"; 19 | public static final String MONGO_SECRETSTRING = "secretString"; 20 | public static final String MONGO_SECRETLONG = "secretLong"; 21 | public static final String MONGO_SECRETBOOLEAN = "secretBoolean"; 22 | public static final String MONGO_SECRETSUBBEAN = "secretSubBean"; 23 | public static final String MONGO_SECRETSTRINGLIST = "secretStringList"; 24 | public static final String MONGO_NONSENSITIVEMAP = "nonSensitiveMap"; 25 | public static final String MONGO_SECRETMAP = "secretMap"; 26 | public static final String MONGO_SECRETSETPRIMITIVE = "secretSetPrimitive"; 27 | public static final String MONGO_SECRETSETSUBDOCUMENT = "secretSetSubDocument"; 28 | 29 | @Id 30 | public String id; 31 | 32 | @Field 33 | public String nonSensitiveData; 34 | 35 | @Field 36 | @Encrypted 37 | public String secretString; 38 | 39 | @Field 40 | @Encrypted 41 | public Long secretLong; 42 | 43 | @Field 44 | @Encrypted 45 | public Boolean secretBoolean; 46 | 47 | @Field 48 | @Encrypted 49 | public MySubBean secretSubBean; 50 | 51 | @Field 52 | public List publicStringList; 53 | 54 | @Field 55 | @Encrypted 56 | public List secretStringList; 57 | 58 | @Field 59 | public MySubBean nonSensitiveSubBean; 60 | 61 | @Field 62 | public List nonSensitiveSubBeanList; 63 | 64 | @Field 65 | public Map nonSensitiveMap; 66 | 67 | @Field 68 | @Encrypted 69 | public Map secretMap; 70 | 71 | @Field 72 | @Encrypted 73 | public Set secretSetPrimitive; 74 | 75 | @Field 76 | @Encrypted 77 | public Set secretSetSubDocument; 78 | 79 | @Field 80 | @Encrypted 81 | public Map> encryptedNestedListMap; 82 | 83 | @Field 84 | public Map> nestedListMap; 85 | 86 | public Map> nestedMapMap; 87 | 88 | @Field 89 | @Encrypted 90 | public List> encryptedNestedListList; 91 | 92 | @Field 93 | public List> nestedListList; 94 | 95 | @Field 96 | public List> nestedListListNotEncrypted; 97 | 98 | @Field 99 | @Version 100 | public Long version; 101 | } 102 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/field/FieldDetectionEncryptSystemTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system.field; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.system.CryptAssert; 5 | import com.bol.system.model.PlainBean; 6 | import jakarta.annotation.PostConstruct; 7 | import org.bson.Document; 8 | import org.bson.types.ObjectId; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.data.mongodb.core.MongoTemplate; 15 | import org.springframework.test.context.junit4.SpringRunner; 16 | 17 | import java.util.Collections; 18 | 19 | import static org.assertj.core.api.Assertions.assertThat; 20 | import static org.springframework.data.mongodb.core.query.Criteria.where; 21 | import static org.springframework.data.mongodb.core.query.Query.query; 22 | 23 | @RunWith(SpringRunner.class) 24 | @SpringBootTest(classes = {FieldDetectionMongoDBConfiguration.class}) 25 | public class FieldDetectionEncryptSystemTest { 26 | 27 | @Autowired protected MongoTemplate mongoTemplate; 28 | @Autowired protected CryptVault cryptVault; 29 | 30 | private CryptAssert cryptAssert; 31 | 32 | @Before 33 | public void cleanDb() { 34 | mongoTemplate.dropCollection(PlainBean.class); 35 | } 36 | 37 | @PostConstruct 38 | void postConstruct() { 39 | cryptAssert = new CryptAssert(cryptVault); 40 | } 41 | 42 | @Test 43 | public void simpleEncryption() { 44 | PlainBean bean = new PlainBean(); 45 | bean.nonSensitiveData = "grass is green"; 46 | bean.sensitiveData = "earth is flat"; 47 | bean.singleSubBean = new PlainBean.PlainSubBean("grass is green", "earth is flat"); 48 | bean.subBeans = Collections.singletonList(new PlainBean.PlainSubBean("grass is green", "earth is flat")); 49 | 50 | mongoTemplate.save(bean); 51 | 52 | PlainBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), PlainBean.class); 53 | 54 | assertThat(fromDb.nonSensitiveData).isEqualTo(bean.nonSensitiveData); 55 | assertThat(fromDb.sensitiveData).isEqualTo(bean.sensitiveData); 56 | assertThat(fromDb.singleSubBean.sensitiveData).isEqualTo(bean.singleSubBean.sensitiveData); 57 | assertThat(fromDb.singleSubBean.nonSensitiveData).isEqualTo(bean.singleSubBean.nonSensitiveData); 58 | assertThat(fromDb.subBeans.get(0).sensitiveData).isEqualTo(bean.subBeans.get(0).sensitiveData); 59 | assertThat(fromDb.subBeans.get(0).nonSensitiveData).isEqualTo(bean.subBeans.get(0).nonSensitiveData); 60 | 61 | Document fromMongo = mongoTemplate.getCollection(PlainBean.MONGO_PLAINBEAN).find(new Document("_id", new ObjectId(bean.id))).first(); 62 | assertThat(fromMongo.get("nonSensitiveData")).isEqualTo(bean.nonSensitiveData); 63 | cryptAssert.assertCryptLength(fromMongo.get("sensitiveData"), bean.sensitiveData.length() + 12); 64 | 65 | Document subBeanDocument = (Document) fromMongo.get("singleSubBean"); 66 | assertThat(subBeanDocument.get("nonSensitiveData")).isEqualTo(bean.singleSubBean.nonSensitiveData); 67 | cryptAssert.assertCryptLength(subBeanDocument.get("sensitiveData"), bean.singleSubBean.sensitiveData.length() + 12); 68 | 69 | Document nested = fromMongo.getList("subBeans", Document.class).get(0); 70 | assertThat(nested.get("nonSensitiveData")).isEqualTo(bean.subBeans.get(0).nonSensitiveData); 71 | cryptAssert.assertCryptLength(nested.get("sensitiveData"), bean.subBeans.get(0).sensitiveData.length() + 12); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | io.github.agoston 7 | spring-data-mongodb-encrypt 8 | jar 9 | spring-data-mongodb-encrypt 10 | 2.9.2 11 | High performance, per-field encryption for spring-data-mongodb 12 | https://github.com/agoston/spring-data-mongodb-encrypt 13 | 14 | 15 | 16 | Apache License v2 17 | https://www.apache.org/licenses/LICENSE-2.0 18 | 19 | 20 | 21 | 22 | https://github.com/agoston/spring-data-mongodb-encrypt 23 | https://github.com/agoston/spring-data-mongodb-encrypt 24 | 25 | 26 | 27 | 28 | Ágoston Horváth 29 | github.com/agoston 30 | 31 | 32 | 33 | 34 | UTF-8 35 | UTF-8 36 | 37 | 38 | 39 | 40 | com.bol 41 | cryptvault 42 | 3-1.0.2 43 | 44 | 45 | org.mongodb 46 | mongodb-driver-sync 47 | [4.5.0,) 48 | provided 49 | 50 | 51 | org.springframework.data 52 | spring-data-mongodb 53 | 4.3.3 54 | provided 55 | 56 | 57 | org.springframework.boot 58 | spring-boot-autoconfigure 59 | 3.3.3 60 | provided 61 | 62 | 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-starter-test 67 | 3.3.3 68 | test 69 | 70 | 71 | junit 72 | junit 73 | 4.13.2 74 | test 75 | 76 | 77 | org.assertj 78 | assertj-core 79 | 3.22.0 80 | test 81 | 82 | 83 | 84 | 85 | 86 | org.apache.maven.plugins 87 | maven-compiler-plugin 88 | 3.13.0 89 | 90 | 17 91 | 17 92 | 93 | 94 | 95 | org.apache.maven.plugins 96 | maven-source-plugin 97 | 3.3.1 98 | 99 | 100 | attach-sources 101 | 102 | jar 103 | 104 | 105 | 106 | 107 | 108 | org.apache.maven.plugins 109 | maven-javadoc-plugin 110 | 3.8.0 111 | 112 | 113 | attach-javadocs 114 | 115 | jar 116 | 117 | 118 | -Xdoclint:none 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /src/main/java/com/bol/secure/CachedEncryptionEventListener.java: -------------------------------------------------------------------------------- 1 | package com.bol.secure; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.crypt.DocumentCryptException; 5 | import com.bol.crypt.FieldCryptException; 6 | import com.bol.reflection.Node; 7 | import com.bol.reflection.ReflectionCache; 8 | import org.bson.Document; 9 | import org.springframework.data.mongodb.core.mapping.event.AfterLoadEvent; 10 | import org.springframework.data.mongodb.core.mapping.event.BeforeSaveEvent; 11 | 12 | import java.util.List; 13 | import java.util.Map; 14 | import java.util.function.Function; 15 | 16 | /** 17 | * Does all reflection at startup. There is no reflection used at runtime. 18 | * Does not support polymorphism and does not need '_class' fields either. 19 | */ 20 | public class CachedEncryptionEventListener extends AbstractEncryptionEventListener { 21 | final ReflectionCache reflectionCache; 22 | 23 | public CachedEncryptionEventListener(CryptVault cryptVault) { 24 | this(cryptVault, FieldEncryptedPredicate.ANNOTATION_PRESENT); 25 | } 26 | 27 | public CachedEncryptionEventListener(CryptVault cryptVault, FieldEncryptedPredicate fieldEncryptedPredicate) { 28 | super(cryptVault); 29 | reflectionCache = new ReflectionCache(fieldEncryptedPredicate); 30 | } 31 | 32 | Node node(Class clazz) { 33 | List children = reflectionCache.reflectRecursive(clazz); 34 | if (!children.isEmpty()) return new Node("", children, Node.Type.DOCUMENT); 35 | return Node.EMPTY; 36 | } 37 | 38 | @Override 39 | public void onAfterLoad(AfterLoadEvent event) { 40 | Document document = event.getDocument(); 41 | 42 | Node node = node(event.getType()); 43 | if (node == Node.EMPTY) return; 44 | 45 | try { 46 | cryptFields(document, node, new Decoder()); 47 | } catch (Exception e) { 48 | Object id = document.get("_id"); 49 | throw new DocumentCryptException(event.getCollectionName(), id, e); 50 | } 51 | } 52 | 53 | @Override 54 | public void onBeforeSave(BeforeSaveEvent event) { 55 | Document document = event.getDocument(); 56 | 57 | Node node = node(event.getSource().getClass()); 58 | if (node == Node.EMPTY) return; 59 | 60 | try { 61 | cryptFields(document, node, new Encoder()); 62 | } catch (Exception e) { 63 | Object id = document.get("_id"); 64 | throw new DocumentCryptException(event.getCollectionName(), id, e); 65 | } 66 | } 67 | 68 | void cryptFields(Object o, Node node, Function crypt) { 69 | try { 70 | switch (node.type) { 71 | case MAP: 72 | cryptMap((Document) o, node, crypt); 73 | break; 74 | 75 | case DOCUMENT: 76 | cryptDocument((Document) o, node, crypt); 77 | break; 78 | 79 | case LIST: 80 | cryptList((List) o, node, crypt); 81 | break; 82 | 83 | default: 84 | throw new IllegalArgumentException("Unknown class field to crypt for field " + node.fieldName + ": " + o.getClass()); 85 | } 86 | } catch (ClassCastException e) { 87 | throw new FieldCryptException(node.fieldName, e); 88 | } 89 | } 90 | 91 | void cryptList(List list, Node node, Function crypt) { 92 | if (node.type != Node.Type.LIST) throw new IllegalArgumentException("Expected list for " + node.fieldName + ", got " + node.type); 93 | 94 | Node mapChildren = node.children.get(0); 95 | for (int i = 0; i < list.size(); i++) { 96 | try { 97 | cryptFields(list.get(i), mapChildren, crypt); 98 | } catch (FieldCryptException e) { 99 | throw e.chain(Integer.toString(i)); 100 | } 101 | } 102 | } 103 | 104 | void cryptMap(Document document, Node node, Function crypt) { 105 | Node mapChildren = node.children.get(0); 106 | for (Map.Entry entry : document.entrySet()) { 107 | try { 108 | cryptFields(entry.getValue(), mapChildren, crypt); 109 | } catch (FieldCryptException e) { 110 | throw e.chain(entry.getKey()); 111 | } 112 | } 113 | } 114 | 115 | void cryptDocument(Document document, Node node, Function crypt) { 116 | for (Node childNode : node.children) { 117 | Object value = document.get(childNode.documentName); 118 | if (value == null) continue; 119 | 120 | if (childNode.type == Node.Type.DIRECT) { 121 | try { 122 | document.put(childNode.documentName, crypt.apply(value)); 123 | } catch (Exception e) { 124 | throw new FieldCryptException(childNode.fieldName, e); 125 | } 126 | } else { 127 | try { 128 | cryptFields(value, childNode, crypt); 129 | } catch (FieldCryptException e) { 130 | throw e.chain(childNode.fieldName); 131 | } 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/main/java/com/bol/secure/ReflectionEncryptionEventListener.java: -------------------------------------------------------------------------------- 1 | package com.bol.secure; 2 | 3 | import com.bol.crypt.CryptVault; 4 | import com.bol.crypt.DocumentCryptException; 5 | import com.bol.crypt.FieldCryptException; 6 | import com.bol.reflection.Node; 7 | import com.bol.reflection.ReflectionCache; 8 | import org.bson.Document; 9 | import org.springframework.data.mongodb.core.mapping.event.AfterLoadEvent; 10 | import org.springframework.data.mongodb.core.mapping.event.BeforeSaveEvent; 11 | 12 | import java.lang.reflect.ParameterizedType; 13 | import java.lang.reflect.Type; 14 | import java.util.Collection; 15 | import java.util.List; 16 | import java.util.Map; 17 | import java.util.function.Function; 18 | 19 | import static com.bol.reflection.Node.Type.DIRECT; 20 | import static com.bol.reflection.ReflectionCache.isPrimitive; 21 | 22 | /** 23 | * This is a reimplementation of {@link CachedEncryptionEventListener}, to support polymorphism. 24 | * This means that while instead of walking by pre-cached class reflection, we have to walk by the Document provided and 25 | * try to match reflection data to it. 26 | */ 27 | public class ReflectionEncryptionEventListener extends AbstractEncryptionEventListener { 28 | 29 | final ReflectionCache reflectionCache; 30 | 31 | public ReflectionEncryptionEventListener(CryptVault cryptVault) { 32 | this(cryptVault, FieldEncryptedPredicate.ANNOTATION_PRESENT); 33 | } 34 | 35 | public ReflectionEncryptionEventListener(CryptVault cryptVault, FieldEncryptedPredicate fieldEncryptedPredicate) { 36 | super(cryptVault); 37 | reflectionCache = new ReflectionCache(fieldEncryptedPredicate); 38 | } 39 | 40 | void cryptDocument(Document document, Class clazz, Function crypt) { 41 | List nodes = reflectionCache.reflectSingle(clazz); 42 | 43 | for (Map.Entry field : document.entrySet()) { 44 | String documentName = field.getKey(); 45 | if (documentName.equals("_class")) continue; 46 | 47 | Node node = find(nodes, documentName); 48 | if (node == null) continue; 49 | 50 | Object fieldValue = field.getValue(); 51 | 52 | if (node.type == DIRECT) { 53 | try { 54 | document.put(documentName, crypt.apply(fieldValue)); 55 | } catch (Exception e) { 56 | throw new FieldCryptException(documentName, e); 57 | } 58 | 59 | } else { 60 | 61 | try { 62 | diveInto(fieldValue, node.field.getGenericType(), crypt); 63 | } catch (FieldCryptException e) { 64 | throw e.chain(documentName); 65 | } 66 | } 67 | } 68 | } 69 | 70 | void diveInto(Object value, Type type, Function crypt) { 71 | // java primitive type; ignore 72 | if (isPrimitive(value.getClass())) return; 73 | 74 | Class reflectiveClass = null; 75 | Type[] typeArguments = null; 76 | if (type instanceof Class) reflectiveClass = (Class) type; 77 | else if (type instanceof ParameterizedType) { 78 | ParameterizedType parameterizedType = (ParameterizedType) type; 79 | Type rawType = parameterizedType.getRawType(); 80 | typeArguments = parameterizedType.getActualTypeArguments(); 81 | if (!(rawType instanceof Class)) throw new IllegalArgumentException("Unknown reflective type class " + type); 82 | reflectiveClass = (Class) rawType; 83 | } else throw new IllegalArgumentException("Unknown reflective type class " + type); 84 | 85 | if (value instanceof Document) { 86 | // Document could be a Map OR a Document; decide based on reflectiveClass 87 | if (Map.class.isAssignableFrom(reflectiveClass)) { 88 | Type subFieldType = typeArguments[1]; 89 | 90 | for (Map.Entry entry : ((Map) value).entrySet()) { 91 | try { 92 | diveInto(entry.getValue(), subFieldType, crypt); 93 | } catch (FieldCryptException e) { 94 | throw e.chain(entry.getKey().toString()); 95 | } 96 | } 97 | 98 | } else { 99 | Class childNode = fetchClassFromField((Document) value); 100 | if (childNode != null) { 101 | cryptDocument((Document) value, childNode, crypt); 102 | } else { 103 | cryptDocument((Document) value, reflectiveClass, crypt); 104 | } 105 | } 106 | } else if (value instanceof List) { 107 | if (Collection.class.isAssignableFrom(reflectiveClass)) { 108 | Type subFieldType = typeArguments[0]; 109 | List list = (List) value; 110 | 111 | for (int i = 0; i < list.size(); i++) { 112 | try { 113 | diveInto(list.get(i), subFieldType, crypt); 114 | } catch (FieldCryptException e) { 115 | throw e.chain(Integer.toString(i)); 116 | } 117 | } 118 | 119 | } else { 120 | throw new IllegalArgumentException("Unknown reflective type class " + type.getClass()); 121 | } 122 | } else { 123 | throw new IllegalArgumentException("Unknown reflective value class: " + value.getClass()); 124 | } 125 | } 126 | 127 | private static Class fetchClassFromField(Document value) { 128 | String className = (String) value.get("_class"); 129 | if (className != null) { 130 | try { 131 | return Class.forName(className); 132 | } catch (ClassNotFoundException ignored) { 133 | throw new IllegalArgumentException("Unknown _class field reference: " + className); 134 | } 135 | } 136 | return null; 137 | } 138 | 139 | private static Node find(List nodes, String documentName) { 140 | for (Node node : nodes) { 141 | if (node.documentName.equals(documentName)) return node; 142 | } 143 | return null; 144 | } 145 | 146 | @Override 147 | public void onAfterLoad(AfterLoadEvent event) { 148 | Document document = event.getDocument(); 149 | try { 150 | cryptDocument(document, event.getType(), new Decoder()); 151 | } catch (Exception e) { 152 | Object id = document.get("_id"); 153 | throw new DocumentCryptException(event.getCollectionName(), id, e); 154 | } 155 | } 156 | 157 | @Override 158 | public void onBeforeSave(BeforeSaveEvent event) { 159 | Document document = event.getDocument(); 160 | try { 161 | cryptDocument(document, event.getSource().getClass(), new Encoder()); 162 | } catch (Exception e) { 163 | Object id = document.get("_id"); 164 | throw new DocumentCryptException(event.getCollectionName(), id, e); 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/main/java/com/bol/reflection/ReflectionCache.java: -------------------------------------------------------------------------------- 1 | package com.bol.reflection; 2 | 3 | import com.bol.secure.FieldEncryptedPredicate; 4 | import org.slf4j.Logger; 5 | import org.slf4j.LoggerFactory; 6 | import org.springframework.data.mongodb.core.mapping.Field; 7 | import org.springframework.util.ReflectionUtils; 8 | 9 | import java.lang.reflect.Modifier; 10 | import java.lang.reflect.ParameterizedType; 11 | import java.lang.reflect.Type; 12 | import java.util.*; 13 | import java.util.concurrent.ConcurrentHashMap; 14 | 15 | public class ReflectionCache { 16 | 17 | private static final Logger LOG = LoggerFactory.getLogger(ReflectionCache.class); 18 | 19 | private final ConcurrentHashMap, List> reflectionCache = new ConcurrentHashMap<>(); 20 | private final FieldEncryptedPredicate fieldEncryptedPredicate; 21 | 22 | public ReflectionCache(FieldEncryptedPredicate fieldEncryptedPredicate) { 23 | this.fieldEncryptedPredicate = fieldEncryptedPredicate; 24 | } 25 | 26 | // used by CachedEncryptionEventListener to gather metadata of a class and all it fields, recursively. 27 | public List reflectRecursive(Class objectClass) { 28 | List nodes = reflectionCache.get(objectClass); 29 | if (nodes != null) return nodes; 30 | 31 | synchronized (this) { 32 | return buildRecursive(objectClass, new HashMap<>()); 33 | } 34 | } 35 | 36 | // building is necessary to avoid putting half-processed data in `reflectionCache` (where it would be returned to other threads) 37 | private List buildRecursive(Class objectClass, HashMap, List> building) { 38 | if (isPrimitive(objectClass)) return Collections.emptyList(); 39 | 40 | List processed = reflectionCache.get(objectClass); 41 | if (processed != null) return processed; 42 | 43 | List processing = building.get(objectClass); 44 | if (processing != null) return processing; 45 | 46 | List nodes = new ArrayList<>(); 47 | building.put(objectClass, nodes); 48 | 49 | ReflectionUtils.doWithFields(objectClass, field -> { 50 | String fieldName = field.getName(); 51 | try { 52 | if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) return; 53 | 54 | String documentName = parseFieldAnnotation(field, fieldName); 55 | 56 | if (fieldEncryptedPredicate.test(field)) { 57 | // direct @Encrypted annotation - crypt the corresponding field of BasicDbObject 58 | nodes.add(new Node(fieldName, documentName, Collections.emptyList(), Node.Type.DIRECT, field)); 59 | 60 | } else { 61 | Class fieldType = field.getType(); 62 | Type fieldGenericType = field.getGenericType(); 63 | 64 | if (Collection.class.isAssignableFrom(fieldType)) { 65 | List children = processParameterizedTypes(fieldGenericType, building); 66 | if (!children.isEmpty()) nodes.add(new Node(fieldName, documentName, unwrap(children), Node.Type.LIST, field)); 67 | 68 | } else if (Map.class.isAssignableFrom(fieldType)) { 69 | List children = processParameterizedTypes(fieldGenericType, building); 70 | if (!children.isEmpty()) nodes.add(new Node(fieldName, documentName, unwrap(children), Node.Type.MAP, field)); 71 | 72 | } else { 73 | // descending into sub-documents 74 | List children = buildRecursive(fieldType, building); 75 | if (!children.isEmpty()) nodes.add(new Node(fieldName, documentName, children, Node.Type.DOCUMENT, field)); 76 | } 77 | } 78 | 79 | } catch (Exception e) { 80 | throw new IllegalArgumentException(objectClass.getName() + "." + fieldName, e); 81 | } 82 | }); 83 | 84 | reflectionCache.put(objectClass, nodes); 85 | 86 | return nodes; 87 | } 88 | 89 | // used by ReflectionEncryptionEventListener to map a single Document 90 | public List reflectSingle(Class objectClass) { 91 | return reflectionCache.computeIfAbsent(objectClass, this::buildSingle); 92 | } 93 | 94 | // FIXME: this is a slimmed down copy-paste of buildRecursive(); find a way to bring Cached and Reflective listener closer together! 95 | private List buildSingle(Class objectClass) { 96 | if (isPrimitive(objectClass)) return Collections.emptyList(); 97 | 98 | List nodes = new ArrayList<>(); 99 | 100 | ReflectionUtils.doWithFields(objectClass, field -> { 101 | String fieldName = field.getName(); 102 | try { 103 | if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) return; 104 | 105 | String documentName = parseFieldAnnotation(field, fieldName); 106 | 107 | if (fieldEncryptedPredicate.test(field)) { 108 | // direct @Encrypted annotation - crypt the corresponding field of BasicDbObject 109 | nodes.add(new Node(fieldName, documentName, Collections.emptyList(), Node.Type.DIRECT, field)); 110 | 111 | } else { 112 | Class fieldType = field.getType(); 113 | 114 | if (Collection.class.isAssignableFrom(fieldType)) { 115 | nodes.add(new Node(fieldName, documentName, Collections.emptyList(), Node.Type.LIST, field)); 116 | 117 | } else if (Map.class.isAssignableFrom(fieldType)) { 118 | nodes.add(new Node(fieldName, documentName, Collections.emptyList(), Node.Type.MAP, field)); 119 | 120 | } else { 121 | nodes.add(new Node(fieldName, documentName, Collections.emptyList(), Node.Type.DOCUMENT, field)); 122 | } 123 | } 124 | 125 | } catch (Exception e) { 126 | throw new IllegalArgumentException(objectClass.getName() + "." + fieldName, e); 127 | } 128 | }); 129 | 130 | return nodes; 131 | } 132 | 133 | List processParameterizedTypes(Type type, HashMap, List> building) { 134 | if (type instanceof Class) { 135 | List children = buildRecursive((Class) type, building); 136 | if (!children.isEmpty()) return Collections.singletonList(new Node(null, children, Node.Type.DOCUMENT)); 137 | 138 | } else if (type instanceof ParameterizedType) { 139 | ParameterizedType subType = (ParameterizedType) type; 140 | Class rawType = (Class) subType.getRawType(); 141 | 142 | if (Collection.class.isAssignableFrom(rawType)) { 143 | List children = processParameterizedTypes(subType.getActualTypeArguments()[0], building); 144 | if (!children.isEmpty()) return Collections.singletonList(new Node(null, children, Node.Type.LIST)); 145 | 146 | } else if (Map.class.isAssignableFrom(rawType)) { 147 | List children = processParameterizedTypes(subType.getActualTypeArguments()[1], building); 148 | if (!children.isEmpty()) return Collections.singletonList(new Node(null, children, Node.Type.MAP)); 149 | 150 | } else { 151 | throw new IllegalArgumentException("Unknown reflective raw type class " + rawType); 152 | } 153 | 154 | } else { 155 | throw new IllegalArgumentException("Unknown reflective type class " + type.getClass()); 156 | } 157 | 158 | return Collections.emptyList(); 159 | } 160 | 161 | static List unwrap(List result) { 162 | if (result.size() != 1) return result; 163 | Node node = result.get(0); 164 | if (node.fieldName != null) return result; 165 | return node.children; 166 | } 167 | 168 | /** 169 | * process custom name in @Field annotation 170 | */ 171 | static String parseFieldAnnotation(java.lang.reflect.Field field, String fieldName) { 172 | Field fieldAnnotation = field.getAnnotation(Field.class); 173 | if (fieldAnnotation != null) { 174 | String name = fieldAnnotation.name(); 175 | 176 | if (!name.isEmpty()) return name; 177 | else { 178 | String value = fieldAnnotation.value(); 179 | if (!value.isEmpty()) return value; 180 | } 181 | } 182 | return fieldName; 183 | } 184 | 185 | // same as ClassUtils.isPrimitiveOrWrapper(), but also includes String 186 | public static boolean isPrimitive(Class clazz) { 187 | return clazz.isPrimitive() || primitiveClasses.contains(clazz); 188 | } 189 | 190 | private static final Set> primitiveClasses = new HashSet<>(Arrays.asList( 191 | Boolean.class, 192 | Byte.class, 193 | Character.class, 194 | Double.class, 195 | Float.class, 196 | Integer.class, 197 | Long.class, 198 | Short.class, 199 | Void.class, 200 | String.class 201 | )); 202 | } 203 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2017 Bol.com 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Maven Central](https://img.shields.io/maven-central/v/io.github.agoston/spring-data-mongodb-encrypt.svg)](http://search.maven.org/#search%7Cga%7C1%7Cio.github.agoston) 2 | [![Build](https://github.com/agoston/spring-data-mongodb-encrypt/actions/workflows/maven.yml/badge.svg)](https://github.com/agoston/spring-data-mongodb-encrypt/actions) 3 | 4 | # spring-data-mongodb-encrypt 5 | 6 | Allows any field to be marked with `@Encrypted` for per-field encryption. 7 | 8 | ## Features 9 | 10 | - integrates transparently into `spring-data-mongodb` 11 | - supports nested Collections, Maps and beans 12 | - high performance (no reflection, optimized encryption) 13 | - key versioning (to help migrating to new key without need to convert data) 14 | - supports 256-bit AES out of the box 15 | - supports any encryption available in Java (via JCE) 16 | - simple (cca. 500 lines of code) 17 | - tested throughly 18 | - no dependencies 19 | 20 | ## Backwards compatibility 21 | 22 | For spring-data 1 projects, please use the [spring-data-1](https://github.com/agoston/spring-data-mongodb-encrypt/tree/spring-data-1) branch. 23 | 24 | For spring-data 2 projects, please use the [spring-data-2](https://github.com/agoston/spring-data-mongodb-encrypt/tree/spring-data-2) branch. 25 | 26 | From version `2.9.0`, java 17 or higher is required. If stuck on older java, use version `2.8.0`. 27 | 28 | ## For the impatient 29 | 30 | Add dependency: 31 | 32 | ```xml 33 | 34 | io.github.agoston 35 | spring-data-mongodb-encrypt 36 | 2.9.2 37 | 38 | ``` 39 | 40 | And add the following to your `application.yml`: 41 | 42 | ```yaml 43 | mongodb.encrypt: 44 | keys: 45 | - version: 1 46 | key: hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA= 47 | ``` 48 | 49 | And you're done! 50 | 51 | Example usage: 52 | 53 | ```java 54 | @Document 55 | public class MyBean { 56 | @Id 57 | public String id; 58 | 59 | // not encrypted 60 | @Field 61 | public String nonSensitiveData; 62 | 63 | // encrypted primitive types 64 | @Field 65 | @Encrypted 66 | public String secretString; 67 | 68 | @Field 69 | @Encrypted 70 | public Long secretLong; 71 | 72 | // encrypted sub-document (MySubBean is serialized, encrypted and stored as byte[]) 73 | @Field 74 | @Encrypted 75 | public MySubBean secretSubBean; 76 | 77 | // encrypted collection (list is serialized, encrypted and stored as byte[]) 78 | @Field 79 | @Encrypted 80 | public List secretStringList; 81 | 82 | // values containing @Encrypted fields are encrypted 83 | @Field 84 | public MySubBean nonSensitiveSubBean; 85 | 86 | // values containing @Encrypted fields are encrypted 87 | @Field 88 | public List nonSensitiveSubBeanList; 89 | 90 | // encrypted map (values containing @Encrypted fields are replaced by encrypted byte[]) 91 | @Field 92 | public Map publicMapWithSecretParts; 93 | } 94 | 95 | public class MySubBean { 96 | @Field 97 | public String nonSensitiveData; 98 | 99 | @Field 100 | @Encrypted 101 | public String secretString; 102 | } 103 | ``` 104 | 105 | Example result in mongodb: 106 | 107 | ``` 108 | > db.mybean.find().pretty() 109 | { 110 | "_id" : ObjectId("59ea0fb902da8d61252b9988"), 111 | "_class" : "com.bol.secure.MyBean", 112 | "nonSensitiveSubBeanList" : [ 113 | { 114 | "nonSensitiveData" : "sky is blue", 115 | "secretString" : BinData(0,"gJNJl3Eij5hX/dJeVgJ/eATIQqahYfUxg89wtKjZL1zxL5h4PTqGqjjn4HbBXbAibw==") 116 | }, 117 | { 118 | "nonSensitiveData" : "grass is green", 119 | "secretString" : BinData(0,"gL+HVZ/OtbESNtL5yWgEYVv0rhT4gdOwYFs7zKx6WGEr1dq3jj84Sq+VhQKl4EthJg==") 120 | } 121 | ] 122 | } 123 | ``` 124 | 125 | ## Manual configuration 126 | 127 | An example if you need to manually configure spring (see also [how tests set up spring mongodb context](src/test/java/com/bol/system/MongoDBConfiguration.java)): 128 | 129 | ```java 130 | @Configuration 131 | public class MongoDBConfiguration extends AbstractMongoClientConfiguration { 132 | 133 | // normally you would use @Value to wire a property here 134 | private static final byte[] secretKey = Base64.getDecoder().decode("hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA="); 135 | private static final byte[] oldKey = Base64.getDecoder().decode("cUzurmCcL+K252XDJhhWI/A/+wxYXLgIm678bwsE2QM="); 136 | 137 | @Override 138 | protected String getDatabaseName() { 139 | return "test"; 140 | } 141 | 142 | @Override 143 | public MongoClient mongoClient() { 144 | return MongoClients.create(); 145 | } 146 | 147 | @Bean 148 | public CryptVault cryptVault() { 149 | return new CryptVault() 150 | .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, oldKey) 151 | .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(1, secretKey) 152 | // can be omitted if it's the highest version 153 | .withDefaultKeyVersion(1); 154 | } 155 | 156 | @Bean 157 | public CachedEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 158 | return new CachedEncryptionEventListener(cryptVault); 159 | } 160 | } 161 | ``` 162 | 163 | ## Starting mongodb for development (docker) 164 | 165 | A `docker-compose.yml` file is provided to allow for quickly running the tests and/or prototyping. 166 | Use with `docker compose up`. 167 | 168 | ## Polymorphism (and why it's bad) 169 | 170 | `spring-data-mongodb` supports polymorphism via a rather questionable mechanism: when the nested bean's type is not deductable from the java generic type, it would simply place an `_class` field in the document to specify the fully qualified class name for deserialization. 171 | This has some very serious drawbacks: 172 | 173 | - Your database becomes tightly coupled with your java code. E.g., you can't just use another code base to access the database, or during refactoring java code, you will have to take extra steps to keep it backwards compatible. Even just changing a java class name or moving it to another package would fail. 174 | 175 | - Storing the fully qualified class name in each subdocument results in a database size increase, up to 10x in worst-case scenario. It also pollutes the object structure, making it harder to read your domain data when examining the database manually. 176 | 177 | - Exposing class names and their properties also has some security implications. 178 | 179 | All in all, the default settings of spring-data-mongodb is quite unoptimal. It is recommended that you do not rely on polymorphism in your spring-data-mongodb data model. 180 | 181 | To circumvent the `_class` feature of `spring-data-mongodb`, install a custom mongo mapper: 182 | 183 | ```java 184 | @Override 185 | @Bean 186 | public MappingMongoConverter mappingMongoConverter(MongoDatabaseFactory databaseFactory, MongoCustomConversions customConversions, MongoMappingContext mappingContext) { 187 | MappingMongoConverter converter = super.mappingMongoConverter(databaseFactory, customConversions, mappingContext); 188 | // NB: without overriding defaultMongoTypeMapper, an _class field is put in every document 189 | // since we know exactly which java class a specific document maps to, this is surplus 190 | converter.setTypeMapper(new DefaultMongoTypeMapper(null)); 191 | return converter; 192 | } 193 | ``` 194 | 195 | ## So OK, polymorphism is bad, but I really really want it! 196 | 197 | Replace the `CachedEncryptionEventListener` by `ReflectionEncryptionEventListener`: 198 | 199 | ```java 200 | @Bean 201 | public ReflectionEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 202 | return new ReflectionEncryptionEventListener(cryptVault); 203 | } 204 | ``` 205 | 206 | or via `application.yml`: 207 | 208 | ```yaml 209 | mongodb.encrypt: 210 | type: reflection 211 | ``` 212 | 213 | Note that using reflection at runtime will come at a performance cost and the drawbacks outlined above. 214 | 215 | ## Ignore decryption failures 216 | 217 | Sometimes (see #17) it is useful to bypass the otherwise rigid decryption framework and allow for a best-effort reading of mongodb documents. Using the `EncryptionEventListener.withSilentDecryptionFailure(true)` allows to bypass these failures and leave the failing fields empty. Example: 218 | 219 | ```java 220 | @Bean 221 | public CachedEncryptionEventListener encryptionEventListener(CryptVault cryptVault) { 222 | return new CachedEncryptionEventListener(cryptVault) 223 | .withSilentDecryptionFailure(true); 224 | } 225 | ``` 226 | 227 | or, via `application.yml`: 228 | ```yaml 229 | mongodb.encrypt: 230 | silent-decryption-failures: true 231 | ``` 232 | 233 | It is also possible to autowire EncryptionEventListener and change this setting on-the-fly. 234 | 235 | ## Keys 236 | 237 | This library supports AES 256 bit keys out of the box. It's possible to extend this, check the source code (`CryptVault` specifically) on how to do so. 238 | 239 | To generate a key, you can use the following command line: 240 | 241 | ``` 242 | dd if=/dev/urandom bs=1 count=32 | base64 243 | ``` 244 | 245 | ## Exchange keys 246 | 247 | It is advisable to rotate your keys every now and then. To do so, define a new key version in `application.yml`: 248 | 249 | ```yaml 250 | mongodb.encrypt: 251 | keys: 252 | - version: 1 253 | key: hqHKBLV83LpCqzKpf8OvutbCs+O5wX5BPu3btWpEvXA= 254 | - version: 2 255 | key: ge2L+MA9jLA8UiUJ4z5fUoK+Lgj2yddlL6EzYIBqb1Q= 256 | ``` 257 | 258 | `spring-data-mongodb-encrypt` would automatically use the highest versioned key for encryption by default, but supports decryption using any of the keys. This allows you to deploy a new key, and either let old data slowly get phased out, or run a nightly load+save batch job to force key migration. Once all old keys are phased out, you may remove the old key from the configuration. 259 | 260 | You can use 261 | 262 | ```yaml 263 | mongodb.encrypt: 264 | default-key: 1 265 | ``` 266 | 267 | to override which version of the defined keys is considered 'default'. 268 | 269 | ## Caveats 270 | 271 | Keep in mind that this library hooks into spring-data's serialization/deserialization only. As such, any operation that bypasses this, for example `findAndModify()` or direct mongo driver accesses (`mongoTemplate.getCollection()`), will not do any encryption/decryption. You can either find another way to achieve your goal via spring-data, or you will have to do the encryption/decryption manually. See the next paragraph for examples. 272 | 273 | ## Encrypt other data 274 | 275 | It's perfectly possible to use the powerful encryption functionality of this library for custom purposes. Example: 276 | 277 | ```java 278 | 279 | @Autowired CryptVault cryptVault; 280 | 281 | // encrypt 282 | byte[] encrypted = cryptVault.encrypt("rock".getBytes()); 283 | 284 | // decrypt 285 | byte[] decrypted = cryptVault.decrypt(encrypted); 286 | 287 | new String(decrypted).equals("rock"); // true 288 | ``` 289 | 290 | If you want to use this library to encrypt arbitrary fields directly via mongo-driver: 291 | 292 | ```java 293 | @Autowired MongoTemplate mongoTemplate; 294 | @Autowired CryptVault cryptVault; 295 | 296 | void store(String id, String secretData) { 297 | byte[] bytes = secretData.getBytes(); 298 | byte[] encrypted = cryptVault.encrypt(bytes); 299 | Binary binary = new Binary(encrypted); 300 | 301 | BasicDBObject dbObject = new BasicDBObject("_id", id); 302 | dbObject.put("blob", binary); 303 | 304 | mongoTemplate.getCollection("blobs").save(dbObject); 305 | } 306 | 307 | String load(String id) { 308 | DBObject result = mongoTemplate.getCollection("blobs").findOne(id); 309 | if (result == null) return ""; 310 | 311 | Object blob = result.get("blob"); 312 | if (blob == null) return ""; 313 | 314 | byte[] encrypted = (byte[]) blob; 315 | byte[] decrypted = cryptVault.decrypt(encrypted); 316 | return new String(decrypted); 317 | } 318 | ``` 319 | 320 | ## Encrypting the whole document 321 | 322 | While it was not the use case for this library, it is very well possible to do whole document encryption with it. 323 | Since the `_id` field (and all the other key fields) always have to be readable by mongodb, the best approach is to extract all the indexed keys into the root of the object, and keep the rest of the data as an @Encrypted sub-document, e.g.: 324 | 325 | ```java 326 | @Field 327 | @Id 328 | public String id; 329 | 330 | @Field 331 | @Indexed 332 | public long otherId; 333 | 334 | @Field 335 | @Encrypted 336 | public SecretData data; 337 | ``` 338 | 339 | If you can't afford to reveal the keys, you could use a high-performing hash like Guava's murmur3 to hash the keys before exposing them, compound or independently. 340 | 341 | ## Encrypting an indexed field 342 | 343 | Since this library encrypts data *before* it is sent to mongodb, there is no point in indexing an encrypted field. However, an approach that worked remarkably well in the past was to put a calculated field next to the encrypted one that contains the hashed value (e.g. Guava's murmur3) of the encrypted field. 344 | 345 | When searching by index, create a hash of the lookup key and search with that against the hashed field. Normally, you'd have 0 (if not exists) or 1 (exists) hits. However, because hashing can result in collisions, you also have to process the case of more than 1 hits, in which case you'd have to load all the matching documents (during which the encrypted field is decrypted by this library), and compare the now-decrypted field to find your exact match. 346 | 347 | 348 | ## Expected size of encrypted field 349 | 350 | The mongodb driver serializes every java object into BSON. Under the hood, we use the very same BSON serialization for maximum compatibility. 351 | 352 | You can expect the following extra sizes when you add an @Encrypted field: 353 | - 17..33 bytes for encryption overhead (salt + padding); 354 | - 12 bytes for BSON serialization overhead. 355 | 356 | This also means that often it is better for both performance and storage size to mark a whole sub-document with @Encrypted instead of half of its fields. 357 | You should check the resulting mongodb document's Binary field sizes to decide. 358 | -------------------------------------------------------------------------------- /src/test/java/com/bol/system/EncryptSystemTest.java: -------------------------------------------------------------------------------- 1 | package com.bol.system; 2 | 3 | import com.bol.crypt.*; 4 | import com.bol.secure.AbstractEncryptionEventListener; 5 | import com.bol.system.model.*; 6 | import com.mongodb.BasicDBObject; 7 | import com.mongodb.DBObject; 8 | import jakarta.annotation.PostConstruct; 9 | import org.bson.Document; 10 | import org.bson.types.Binary; 11 | import org.bson.types.ObjectId; 12 | import org.junit.Before; 13 | import org.junit.Test; 14 | import org.junit.internal.Throwables; 15 | import org.springframework.beans.factory.annotation.Autowired; 16 | import org.springframework.data.mongodb.core.MongoTemplate; 17 | import org.springframework.data.mongodb.core.query.Query; 18 | import org.springframework.test.annotation.DirtiesContext; 19 | import org.springframework.test.util.ReflectionTestUtils; 20 | 21 | import java.util.*; 22 | import java.util.concurrent.ExecutorService; 23 | import java.util.concurrent.Executors; 24 | import java.util.concurrent.Future; 25 | import java.util.concurrent.TimeUnit; 26 | 27 | import static com.bol.crypt.CryptVault.fromSignedByte; 28 | import static com.bol.system.model.InitBean.*; 29 | import static com.bol.system.model.MyBean.MONGO_NONSENSITIVEDATA; 30 | import static com.bol.system.model.MyBean.MONGO_SECRETSTRING; 31 | import static org.assertj.core.api.Assertions.assertThat; 32 | import static org.springframework.data.mongodb.core.query.Criteria.where; 33 | import static org.springframework.data.mongodb.core.query.Query.query; 34 | 35 | // FIXME: BSON sizes test for map and set is a bit flaky, need to investigate exact on-disk binary format deeper 36 | public abstract class EncryptSystemTest { 37 | 38 | @Autowired protected MongoTemplate mongoTemplate; 39 | @Autowired protected CryptVault cryptVault; 40 | @Autowired protected AbstractEncryptionEventListener abstractEncryptionEventListener; 41 | 42 | private CryptAssert cryptAssert; 43 | 44 | @Before 45 | public void cleanDb() { 46 | mongoTemplate.dropCollection(MyBean.class); 47 | mongoTemplate.dropCollection(Person.class); 48 | mongoTemplate.dropCollection(RenamedField.class); 49 | mongoTemplate.dropCollection(PrimitiveField.class); 50 | mongoTemplate.dropCollection(InitBean.class); 51 | } 52 | 53 | @PostConstruct 54 | void postConstruct() { 55 | cryptAssert = new CryptAssert(cryptVault); 56 | } 57 | 58 | @Test 59 | public void simpleEncryption() { 60 | MyBean bean = new MyBean(); 61 | bean.nonSensitiveData = "grass is green"; 62 | bean.secretString = "earth is flat "; 63 | bean.secretLong = 95459L; 64 | bean.secretBoolean = true; 65 | bean.secretStringList = Arrays.asList("ear", "all", "I truly am a very very long string. I truly am a very very long string. I truly am a very very long string."); 66 | bean.publicStringList = Arrays.asList("ear", "all"); 67 | mongoTemplate.save(bean); 68 | 69 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 70 | 71 | assertThat(fromDb.nonSensitiveData).isEqualTo(bean.nonSensitiveData); 72 | assertThat(fromDb.secretString).isEqualTo(bean.secretString); 73 | assertThat(fromDb.secretLong).isEqualTo(bean.secretLong); 74 | assertThat(fromDb.secretBoolean).isEqualTo(bean.secretBoolean); 75 | assertThat(fromDb.secretStringList).isEqualTo(bean.secretStringList); 76 | 77 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new Document("_id", new ObjectId(bean.id))).first(); 78 | assertThat(fromMongo.get(MyBean.MONGO_NONSENSITIVEDATA)).isEqualTo(bean.nonSensitiveData); 79 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETSTRING), bean.secretString.length() + 12); 80 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETLONG), 8); 81 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETBOOLEAN), 1); 82 | // 12 is a magic constant that seems to be the overhead when serializing list of strings to BSON with mongo driver 3.4.2 83 | int expectedLength = 12 + bean.secretStringList.stream().mapToInt(s -> s.length() + 8).sum(); 84 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETSTRINGLIST), expectedLength); 85 | } 86 | 87 | @Test 88 | public void checkEncryptPrimitives() { 89 | PrimitiveField bean = new PrimitiveField(); 90 | bean.id = UUID.randomUUID(); 91 | bean.primitiveInt = 1; 92 | bean.encryptedPrimitiveInt = 2; 93 | bean.data = new byte[]{1, 2, 3}; 94 | mongoTemplate.save(bean); 95 | 96 | PrimitiveField fromDb = mongoTemplate.findOne(new Query(), PrimitiveField.class); 97 | 98 | assertThat(fromDb.id).isEqualTo(bean.id); 99 | assertThat(fromDb.primitiveInt).isEqualTo(bean.primitiveInt); 100 | assertThat(fromDb.encryptedPrimitiveInt).isEqualTo(bean.encryptedPrimitiveInt); 101 | assertThat(fromDb.data).isEqualTo(bean.data); 102 | 103 | // FIXME: test for DB encoding of java primitives 104 | } 105 | 106 | @Test 107 | public void testCustomFieldnameWorks() { 108 | RenamedField bean = new RenamedField(); 109 | bean.notSecret = "not secret"; 110 | bean.someSecret = "whacky pass"; 111 | 112 | mongoTemplate.save(bean); 113 | 114 | RenamedField fromDb = mongoTemplate.findOne(new Query(), RenamedField.class); 115 | 116 | assertThat(fromDb.notSecret).isEqualTo(bean.notSecret); 117 | assertThat(fromDb.someSecret).isEqualTo(bean.someSecret); 118 | 119 | Document fromMongo = mongoTemplate.getCollection(RenamedField.MONGO_RENAMEDFIELD).find().first(); 120 | assertThat(fromMongo.get(RenamedField.MONGO_NOTSECRET)).isEqualTo(bean.notSecret); 121 | assertThat(fromMongo.get(RenamedField.MONGO_SOMESECRET)).isNull(); 122 | assertThat(fromMongo.get(RenamedField.MONGO_PASSWORD)).isInstanceOf(Binary.class); 123 | } 124 | 125 | @Test 126 | public void checkEncryptedSubdocument() { 127 | MyBean bean = new MyBean(); 128 | MySubBean subBean = new MySubBean("sky is blue", " earth is round"); 129 | bean.secretSubBean = subBean; 130 | mongoTemplate.save(bean); 131 | 132 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 133 | 134 | assertThat(fromDb.secretSubBean.nonSensitiveData).isEqualTo(bean.secretSubBean.nonSensitiveData); 135 | assertThat(fromDb.secretSubBean.secretString).isEqualTo(bean.secretSubBean.secretString); 136 | 137 | Document doc = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 138 | DBObject fromMongo = new BasicDBObject(doc); 139 | 140 | int expectedLength = 12 141 | + MySubBean.MONGO_NONSENSITIVEDATA.length() + subBean.secretString.length() + 7 142 | + MySubBean.MONGO_SECRETSTRING.length() + subBean.nonSensitiveData.length() + 7; 143 | 144 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETSUBBEAN), expectedLength); 145 | } 146 | 147 | @Test 148 | public void checkNonEncryptedSubdocument() { 149 | MyBean bean = new MyBean(); 150 | MySubBean subBean = new MySubBean("sky is blue", " earth is round"); 151 | bean.nonSensitiveSubBean = subBean; 152 | mongoTemplate.save(bean); 153 | 154 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 155 | 156 | assertThat(fromDb.nonSensitiveSubBean.nonSensitiveData).isEqualTo(bean.nonSensitiveSubBean.nonSensitiveData); 157 | assertThat(fromDb.nonSensitiveSubBean.secretString).isEqualTo(bean.nonSensitiveSubBean.secretString); 158 | 159 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 160 | Document subMongo = (Document) fromMongo.get(MyBean.MONGO_NONSENSITIVESUBBEAN); 161 | 162 | assertThat(subMongo.get(MySubBean.MONGO_NONSENSITIVEDATA)).isEqualTo(subBean.nonSensitiveData); 163 | cryptAssert.assertCryptLength(subMongo.get(MySubBean.MONGO_SECRETSTRING), subBean.secretString.length() + 12); 164 | } 165 | 166 | @Test 167 | public void checkNonEncryptedSubdocumentList() { 168 | MyBean bean = new MyBean(); 169 | bean.nonSensitiveSubBeanList = Arrays.asList( 170 | new MySubBean("sky is blue", "earth is round "), 171 | new MySubBean(" grass is green ", " earth is cubic ") 172 | ); 173 | mongoTemplate.save(bean); 174 | 175 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 176 | 177 | for (int i = 0; i < bean.nonSensitiveSubBeanList.size(); i++) { 178 | MySubBean subBean = bean.nonSensitiveSubBeanList.get(i); 179 | MySubBean subDb = fromDb.nonSensitiveSubBeanList.get(i); 180 | assertThat(subBean.secretString).isEqualTo(subDb.secretString); 181 | assertThat(subBean.nonSensitiveData).isEqualTo(subDb.nonSensitiveData); 182 | } 183 | 184 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 185 | ArrayList subMongo = (ArrayList) fromMongo.get(MyBean.MONGO_NONSENSITIVESUBBEANLIST); 186 | 187 | for (int i = 0; i < bean.nonSensitiveSubBeanList.size(); i++) { 188 | Document basicDBObject = (Document) subMongo.get(i); 189 | MySubBean subBean = bean.nonSensitiveSubBeanList.get(i); 190 | assertThat(basicDBObject.get(MySubBean.MONGO_NONSENSITIVEDATA)).isEqualTo(subBean.nonSensitiveData); 191 | cryptAssert.assertCryptLength(basicDBObject.get(MySubBean.MONGO_SECRETSTRING), subBean.secretString.length() + 12); 192 | } 193 | } 194 | 195 | @Test 196 | public void checkNonEncryptedMap() { 197 | MyBean bean = new MyBean(); 198 | Map map = new HashMap<>(); 199 | map.put("one", new MySubBean("sky is blue", " earth is round")); 200 | map.put("two", new MySubBean("grass is green", "earth is flat")); 201 | bean.nonSensitiveMap = map; 202 | mongoTemplate.save(bean); 203 | 204 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 205 | 206 | assertThat(fromDb.nonSensitiveMap.get("one").secretString).isEqualTo(bean.nonSensitiveMap.get("one").secretString); 207 | assertThat(fromDb.nonSensitiveMap.get("one").nonSensitiveData).isEqualTo(bean.nonSensitiveMap.get("one").nonSensitiveData); 208 | assertThat(fromDb.nonSensitiveMap.get("two").secretString).isEqualTo(bean.nonSensitiveMap.get("two").secretString); 209 | assertThat(fromDb.nonSensitiveMap.get("two").nonSensitiveData).isEqualTo(bean.nonSensitiveMap.get("two").nonSensitiveData); 210 | 211 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 212 | 213 | Document mapMongo = (Document) fromMongo.get(MyBean.MONGO_NONSENSITIVEMAP); 214 | Document oneMongo = (Document) mapMongo.get("one"); 215 | Document twoMongo = (Document) mapMongo.get("two"); 216 | 217 | 218 | assertThat(oneMongo.get(MySubBean.MONGO_NONSENSITIVEDATA)).isEqualTo(map.get("one").nonSensitiveData); 219 | assertThat(twoMongo.get(MySubBean.MONGO_NONSENSITIVEDATA)).isEqualTo(map.get("two").nonSensitiveData); 220 | cryptAssert.assertCryptLength(oneMongo.get(MySubBean.MONGO_SECRETSTRING), map.get("one").secretString.length() + 12); 221 | cryptAssert.assertCryptLength(twoMongo.get(MySubBean.MONGO_SECRETSTRING), map.get("two").secretString.length() + 12); 222 | } 223 | 224 | @Test 225 | public void checkEncryptedMap() { 226 | MyBean bean = new MyBean(); 227 | Map map = new HashMap<>(); 228 | map.put("one", new MySubBean("sky is blue", " earth is round")); 229 | map.put("two", new MySubBean("grass is green", "earth is flat")); 230 | bean.secretMap = map; 231 | mongoTemplate.save(bean); 232 | 233 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 234 | 235 | assertThat(fromDb.secretMap.get("one").secretString).isEqualTo(bean.secretMap.get("one").secretString); 236 | assertThat(fromDb.secretMap.get("one").nonSensitiveData).isEqualTo(bean.secretMap.get("one").nonSensitiveData); 237 | assertThat(fromDb.secretMap.get("two").secretString).isEqualTo(bean.secretMap.get("two").secretString); 238 | assertThat(fromDb.secretMap.get("two").nonSensitiveData).isEqualTo(bean.secretMap.get("two").nonSensitiveData); 239 | 240 | Document doc = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 241 | DBObject fromMongo = new BasicDBObject(doc); 242 | int expectedLength = 12 243 | + "one".length() + 7 244 | + "two".length() + 7 245 | + MySubBean.MONGO_NONSENSITIVEDATA.length() + map.get("one").secretString.length() + 7 246 | + MySubBean.MONGO_SECRETSTRING.length() + map.get("one").nonSensitiveData.length() + 7 247 | + MySubBean.MONGO_NONSENSITIVEDATA.length() + map.get("two").secretString.length() + 7 248 | + MySubBean.MONGO_SECRETSTRING.length() + map.get("two").nonSensitiveData.length() + 7; 249 | 250 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETMAP), expectedLength); 251 | } 252 | 253 | @Test 254 | public void checkEncryptedSetPrimitive() { 255 | MyBean bean = new MyBean(); 256 | Set set = new HashSet<>(); 257 | set.add("one"); 258 | set.add("two"); 259 | bean.secretSetPrimitive = set; 260 | mongoTemplate.save(bean); 261 | 262 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 263 | 264 | assertThat(fromDb.secretSetPrimitive.contains("one")).isEqualTo(true); 265 | assertThat(fromDb.secretSetPrimitive.contains("two")).isEqualTo(true); 266 | assertThat(fromDb.secretSetPrimitive.size()).isEqualTo(2); 267 | 268 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 269 | int expectedLength = 12 270 | + "one".length() + 7 271 | + "two".length() + 7; 272 | 273 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETSETPRIMITIVE), expectedLength); 274 | } 275 | 276 | @Test 277 | public void checkEncryptedSetSubDocument() { 278 | MyBean bean = new MyBean(); 279 | Set set = new HashSet<>(); 280 | set.add(new MySubBean("sky is blue", " earth is round")); 281 | set.add(new MySubBean("grass is green", "earth is flat")); 282 | bean.secretSetSubDocument = set; 283 | mongoTemplate.save(bean); 284 | 285 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 286 | 287 | assertThat(fromDb.secretSetSubDocument.size()).isEqualTo(2); 288 | assertThat(fromDb.secretSetSubDocument.stream().anyMatch(s -> Objects.equals(s.nonSensitiveData, "sky is blue"))).isTrue(); 289 | assertThat(fromDb.secretSetSubDocument.stream().anyMatch(s -> Objects.equals(s.nonSensitiveData, "grass is green"))).isTrue(); 290 | 291 | Document doc = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 292 | DBObject fromMongo = new BasicDBObject(doc); 293 | 294 | int expectedLength = 12 295 | + MySubBean.MONGO_NONSENSITIVEDATA.length() + "sky is blue".length() + 12 296 | + MySubBean.MONGO_SECRETSTRING.length() + " earth is round".length() + 12 297 | + MySubBean.MONGO_NONSENSITIVEDATA.length() + "grass is green".length() + 12 298 | + MySubBean.MONGO_SECRETSTRING.length() + "earth is flat".length() + 12; 299 | 300 | cryptAssert.assertCryptLength(fromMongo.get(MyBean.MONGO_SECRETSETSUBDOCUMENT), expectedLength); 301 | } 302 | 303 | 304 | @Test 305 | public void consecutiveEncryptsDifferentResults() { 306 | MyBean bean = new MyBean(); 307 | bean.nonSensitiveData = "grass is green"; 308 | bean.secretString = "earth is flat"; 309 | mongoTemplate.save(bean); 310 | 311 | Document fromMongo1 = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 312 | 313 | Binary cryptedSecretBinary1 = (Binary) fromMongo1.get(MyBean.MONGO_SECRETSTRING); 314 | byte[] cryptedSecret1 = cryptedSecretBinary1.getData(); 315 | mongoTemplate.save(bean); 316 | 317 | Document fromMongo2 = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 318 | Binary cryptedSecretBinary2 = (Binary) fromMongo2.get(MyBean.MONGO_SECRETSTRING); 319 | byte[] cryptedSecret2 = cryptedSecretBinary2.getData(); 320 | 321 | assertThat(cryptedSecret1.length).isEqualTo(cryptedSecret2.length); 322 | // version 323 | assertThat(cryptedSecret1[0]).isEqualTo(cryptedSecret2[0]); 324 | 325 | // chances of having the same bytes in the same positions is negligible 326 | int equals = 0; 327 | for (int i = 1; i < cryptedSecret1.length; i++) { 328 | if (cryptedSecret1[i] == cryptedSecret2[i]) equals++; 329 | } 330 | 331 | assertThat(equals).isLessThan(cryptedSecret1.length / 10).as("crypted fields look too much alike"); 332 | } 333 | 334 | @Test 335 | public void testEncryptedNestedListMap() { 336 | MyBean bean = new MyBean(); 337 | Map> map = new HashMap<>(); 338 | map.put("one", Arrays.asList(new MySubBean("one1", "one2"), new MySubBean("one3", "one4"))); 339 | map.put("two", Arrays.asList(new MySubBean("two1", "two2"), new MySubBean("two3", "two4"))); 340 | bean.encryptedNestedListMap = map; 341 | mongoTemplate.save(bean); 342 | 343 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 344 | 345 | assertThat(fromDb.encryptedNestedListMap.get("one").get(1).secretString).isEqualTo("one4"); 346 | 347 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 348 | 349 | Object binarySecret = fromMongo.get("encryptedNestedListMap"); 350 | assertThat(binarySecret).isInstanceOf(Binary.class); 351 | 352 | assertThat(((Binary) binarySecret).getData()).isInstanceOf(byte[].class); 353 | } 354 | 355 | @Test 356 | public void testNestedListMap() { 357 | MyBean bean = new MyBean(); 358 | Map> map = new HashMap<>(); 359 | map.put("one", Arrays.asList(new MySubBean("one1", "one2"), new MySubBean("one3", "one4"))); 360 | map.put("two", Arrays.asList(new MySubBean("two1", "two2"), new MySubBean("two3", "two4"))); 361 | bean.nestedListMap = map; 362 | mongoTemplate.save(bean); 363 | 364 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 365 | 366 | assertThat(fromDb.nestedListMap.get("one").get(1).secretString).isEqualTo("one4"); 367 | 368 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 369 | Document dbNestedListMap = (Document) fromMongo.get("nestedListMap"); 370 | ArrayList dbNestedList = (ArrayList) dbNestedListMap.get("one"); 371 | Document dbBean = (Document) dbNestedList.get(1); 372 | Object encryptedField = dbBean.get("secretString"); 373 | assertThat(encryptedField).isInstanceOf(Binary.class); 374 | Object encryptedFieldData = ((Binary) encryptedField).getData(); 375 | assertThat(encryptedFieldData).isInstanceOf(byte[].class); 376 | } 377 | 378 | @Test 379 | public void testNestedMapMap() { 380 | MyBean bean = new MyBean(); 381 | Map innerMap = new HashMap<>(); 382 | innerMap.put("one", new MySubBean("one1", "one2")); 383 | innerMap.put("two", new MySubBean("two1", "two2")); 384 | 385 | Map> outerMap = new HashMap<>(); 386 | outerMap.put("inner", innerMap); 387 | bean.nestedMapMap = outerMap; 388 | 389 | mongoTemplate.save(bean); 390 | 391 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 392 | 393 | assertThat(fromDb.nestedMapMap.get("inner").get("two").secretString).isEqualTo("two2"); 394 | 395 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new BasicDBObject("_id", new ObjectId(bean.id))).first(); 396 | Document dbNestedMapMap = (Document) fromMongo.get("nestedMapMap"); 397 | Document dbNestedMapInner = (Document) dbNestedMapMap.get("inner"); 398 | Document dbBean = (Document) dbNestedMapInner.get("one"); 399 | Object encryptedField = dbBean.get("secretString"); 400 | assertThat(encryptedField).isInstanceOf(Binary.class); 401 | Object encryptedFieldData = ((Binary) encryptedField).getData(); 402 | assertThat(encryptedFieldData).isInstanceOf(byte[].class); 403 | } 404 | 405 | @Test 406 | public void testNestedListList() { 407 | MyBean bean = new MyBean(); 408 | List> list = new ArrayList<>(); 409 | list.add(Arrays.asList(new MySubBean("one1", "one2"), new MySubBean("one3", "one4"))); 410 | list.add(Arrays.asList(new MySubBean("two1", "two2"), new MySubBean("two3", "two4"))); 411 | bean.nestedListList = list; 412 | mongoTemplate.save(bean); 413 | 414 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 415 | 416 | assertThat(fromDb.nestedListList.get(0).get(1).secretString).isEqualTo("one4"); 417 | assertThat(fromDb.nestedListList.get(0).get(1).nonSensitiveData).isEqualTo("one3"); 418 | 419 | Document doc = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new Document("_id", new ObjectId(bean.id))).first(); 420 | ArrayList nestedListList = (ArrayList) doc.get("nestedListList"); 421 | ArrayList nestedList = (ArrayList) nestedListList.get(1); 422 | Document dbDoc = (Document) nestedList.get(0); 423 | Object encryptedField = dbDoc.get("secretString"); 424 | assertThat(encryptedField).isInstanceOf(Binary.class); 425 | } 426 | 427 | @Test 428 | public void testNestedListListNotEncrypted() { 429 | MyBean bean = new MyBean(); 430 | List> list = new ArrayList<>(); 431 | list.add(Arrays.asList(new MySubBeanNotEncrypted("one1", "one2"), new MySubBeanNotEncrypted("one3", "one4"))); 432 | list.add(Arrays.asList(new MySubBeanNotEncrypted("two1", "two2"), new MySubBeanNotEncrypted("two3", "two4"))); 433 | bean.nestedListListNotEncrypted = list; 434 | mongoTemplate.save(bean); 435 | 436 | MyBean fromDb = mongoTemplate.findOne(query(where("_id").is(bean.id)), MyBean.class); 437 | 438 | assertThat(fromDb.nestedListListNotEncrypted.get(0).get(1).nonSensitiveData1).isEqualTo("one3"); 439 | } 440 | 441 | @Test 442 | public void checkSuperclassInheritedFields() { 443 | Person person = new Person(); 444 | Ssn ssn = new Ssn(); 445 | person.ssn = ssn; 446 | ssn.ssn = "my ssn"; 447 | ssn.someSecret = "my secret"; 448 | ssn.notSecret = "not secret"; 449 | mongoTemplate.save(person); 450 | 451 | Person fromDb = mongoTemplate.findOne(query(where("_id").is(person.id)), Person.class); 452 | assertThat(fromDb.ssn.notSecret).isEqualTo(person.ssn.notSecret); 453 | assertThat(fromDb.ssn.someSecret).isEqualTo(person.ssn.someSecret); 454 | assertThat(fromDb.ssn.ssn).isEqualTo(person.ssn.ssn); 455 | 456 | Document fromMongo = mongoTemplate.getCollection(Person.MONGO_PERSON).find(new Document("_id", new ObjectId(person.id))).first(); 457 | Document dbBean = (Document) fromMongo.get("ssn"); 458 | Object encryptedField = dbBean.get("ssn"); 459 | assertThat(encryptedField).isInstanceOf(Binary.class); 460 | Object encryptedFieldData = ((Binary) encryptedField).getData(); 461 | assertThat(encryptedFieldData).isInstanceOf(byte[].class); 462 | Object encryptedInheritedField = dbBean.get("someSecret"); 463 | assertThat(encryptedInheritedField).isInstanceOf(Binary.class); 464 | Object encryptedInheritedFieldData = ((Binary) encryptedInheritedField).getData(); 465 | assertThat(encryptedInheritedFieldData).isInstanceOf(byte[].class); 466 | Object noncryptedInheritedField = dbBean.get("notSecret"); 467 | assertThat(noncryptedInheritedField).isInstanceOf(String.class); 468 | } 469 | 470 | @Test(expected = DocumentCryptException.class) 471 | @DirtiesContext 472 | public void checkWrongKeyRoot() { 473 | // save to db, version = 0 474 | MyBean bean = new MyBean(); 475 | bean.secretString = "secret"; 476 | bean.nonSensitiveData = getClass().getSimpleName(); 477 | mongoTemplate.insert(bean); 478 | 479 | // override version 0's key 480 | ReflectionTestUtils.setField(cryptVault, "cryptVersions", new CryptVersion[256]); 481 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 482 | 483 | try { 484 | mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 485 | } catch (DocumentCryptException e) { 486 | assertCryptException(e, "mybean", null, "secretString"); 487 | throw e; 488 | } 489 | } 490 | 491 | @Test(expected = DocumentCryptException.class) 492 | @DirtiesContext 493 | public void checkWrongKeyCustomId() { 494 | // save to db, version = 0 495 | MyBean bean = new MyBean(); 496 | bean.id = "customId"; 497 | bean.secretString = "secret"; 498 | bean.nonSensitiveData = getClass().getSimpleName(); 499 | mongoTemplate.insert(bean); 500 | 501 | // override version 0's key 502 | ReflectionTestUtils.setField(cryptVault, "cryptVersions", new CryptVersion[256]); 503 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 504 | 505 | try { 506 | mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 507 | } catch (DocumentCryptException e) { 508 | assertCryptException(e, "mybean", null, "secretString"); 509 | throw e; 510 | } 511 | } 512 | 513 | @Test(expected = DocumentCryptException.class) 514 | @DirtiesContext 515 | public void checkWrongKeyDeep() { 516 | // save to db, version = 0 517 | MyBean bean = new MyBean(); 518 | bean.nonSensitiveSubBean = new MySubBean(); 519 | bean.nonSensitiveSubBean.secretString = "secret"; 520 | bean.nonSensitiveData = getClass().getSimpleName(); 521 | mongoTemplate.insert(bean); 522 | 523 | // override version 0's key 524 | ReflectionTestUtils.setField(cryptVault, "cryptVersions", new CryptVersion[256]); 525 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 526 | 527 | try { 528 | mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 529 | } catch (DocumentCryptException e) { 530 | assertCryptException(e, "mybean", null, "nonSensitiveSubBean.secretString"); 531 | throw e; 532 | } 533 | } 534 | 535 | @Test(expected = DocumentCryptException.class) 536 | @DirtiesContext 537 | public void checkWrongKeyDeepMap() { 538 | // save to db, version = 0 539 | MyBean bean = new MyBean(); 540 | bean.nonSensitiveMap = new HashMap<>(); 541 | bean.nonSensitiveMap.put("one", new MySubBean()); 542 | bean.nonSensitiveMap.get("one").secretString = "secret"; 543 | bean.nonSensitiveData = getClass().getSimpleName(); 544 | mongoTemplate.insert(bean); 545 | 546 | // override version 0's key 547 | ReflectionTestUtils.setField(cryptVault, "cryptVersions", new CryptVersion[256]); 548 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 549 | 550 | try { 551 | mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 552 | } catch (DocumentCryptException e) { 553 | assertCryptException(e, "mybean", null, "nonSensitiveMap.one.secretString"); 554 | throw e; 555 | } 556 | } 557 | 558 | @Test(expected = DocumentCryptException.class) 559 | @DirtiesContext 560 | public void checkWrongKeyDeepList() { 561 | // save to db, version = 0 562 | MyBean bean = new MyBean(); 563 | bean.nonSensitiveSubBeanList = new ArrayList<>(); 564 | bean.nonSensitiveSubBeanList.add(new MySubBean()); 565 | bean.nonSensitiveSubBeanList.get(0).secretString = "secret"; 566 | bean.nonSensitiveData = getClass().getSimpleName(); 567 | mongoTemplate.insert(bean); 568 | 569 | // override version 0's key 570 | ReflectionTestUtils.setField(cryptVault, "cryptVersions", new CryptVersion[256]); 571 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 572 | 573 | try { 574 | mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 575 | } catch (DocumentCryptException e) { 576 | assertCryptException(e, "mybean", null, "nonSensitiveSubBeanList.0.secretString"); 577 | throw e; 578 | } 579 | } 580 | 581 | @Test 582 | @DirtiesContext 583 | public void checkWrongKeySilentFailure() { 584 | // save to db, version = 0 585 | MyBean bean = new MyBean(); 586 | bean.secretString = "secret"; 587 | bean.nonSensitiveData = getClass().getSimpleName(); 588 | mongoTemplate.insert(bean); 589 | 590 | // override version 0's key 591 | ReflectionTestUtils.setField(cryptVault, "cryptVersions", new CryptVersion[256]); 592 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(0, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 593 | abstractEncryptionEventListener.withSilentDecryptionFailure(true); 594 | 595 | List all = mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 596 | assertThat(all).hasSize(1); 597 | 598 | assertThat(all.get(0).secretString).isNull(); 599 | assertThat(all.get(0).nonSensitiveData).isNotNull(); 600 | } 601 | 602 | @Test 603 | @DirtiesContext 604 | public void checkDefaultEncryptVersion() { 605 | cryptVault 606 | .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(1, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")) 607 | .with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(2, Base64.getDecoder().decode("IqWTpi549pJDZ1kuc9HppcMxtPfu2SP6Idlh+tz4LL4=")); 608 | 609 | // default key version should now be 2 610 | byte[] result = cryptedResultInDb("1234"); 611 | assertThat(result.length).isEqualTo(cryptVault.expectedCryptedLength(4 + 12)); 612 | assertThat(fromSignedByte(result[0])).isEqualTo(2); 613 | } 614 | 615 | @Test 616 | @DirtiesContext 617 | public void checkMultipleEncryptVersion() { 618 | // default key version should now be 2 619 | byte[] result1 = cryptedResultInDb("versioning test"); 620 | 621 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(1, Base64.getDecoder().decode("aic7QGYCCSHyy7gYRCyNTpPThbomw1/dtWl4bocyTnU=")); 622 | byte[] result2 = cryptedResultInDb("versioning test"); 623 | 624 | cryptVault.with256BitAesCbcPkcs5PaddingAnd128BitSaltKey(2, Base64.getDecoder().decode("IqWTpi549pJDZ1kuc9HppcMxtPfu2SP6Idlh+tz4LL4=")); 625 | byte[] result3 = cryptedResultInDb("versioning test"); 626 | 627 | assertThat(fromSignedByte(result1[0])).isEqualTo(0); 628 | assertThat(fromSignedByte(result2[0])).isEqualTo(1); 629 | assertThat(fromSignedByte(result3[0])).isEqualTo(2); 630 | 631 | // sanity check that all of the versions are encrypted 632 | List all = mongoTemplate.find(query(where(MONGO_SECRETSTRING).is("versioning test")), MyBean.class); 633 | assertThat(all).hasSize(0); 634 | 635 | all = mongoTemplate.find(query(where(MONGO_NONSENSITIVEDATA).is(getClass().getSimpleName())), MyBean.class); 636 | assertThat(all).hasSize(3); 637 | 638 | // check that all 3 different versions are decrypted 639 | for (MyBean bean : all) { 640 | assertThat(bean.secretString).isEqualTo("versioning test"); 641 | } 642 | } 643 | 644 | // ReflectionCache is not initialized yet, and we hammer building it in parallel 645 | @Test 646 | public void checkParallelInitialization() { 647 | int nThreads = Math.max(4, Runtime.getRuntime().availableProcessors()); 648 | ExecutorService executorService = Executors.newFixedThreadPool(nThreads); 649 | 650 | ArrayList> futures = new ArrayList<>(); 651 | 652 | for (int i = 0; i < nThreads; i++) { 653 | futures.add( 654 | executorService.submit(() -> { 655 | InitBean initBean = new InitBean(); 656 | initBean.addSubBean("my data 2"); 657 | initBean.data1 = "my data 1"; 658 | mongoTemplate.save(initBean); 659 | return initBean.id; 660 | }) 661 | ); 662 | } 663 | 664 | futures.forEach(f -> { 665 | try { 666 | String id = f.get(10, TimeUnit.SECONDS); 667 | assertThat(id).isNotNull(); 668 | 669 | Document fromMongo = mongoTemplate.getCollection(InitBean.MONGO_INITBEAN).find(new Document("_id", new ObjectId(id))).first(); 670 | Object data1 = fromMongo.get(MONGO_DATA1); 671 | assertThat(data1).isInstanceOf(Binary.class); 672 | List list = (List)fromMongo.get(MONGO_SUB_BEANS); 673 | assertThat(list).hasSize(1); 674 | Document subBean = (Document)list.get(0); 675 | assertThat(subBean.get(MONGO_DATA2)).isInstanceOf(Binary.class); 676 | } catch (Exception e) { 677 | } 678 | }); 679 | } 680 | 681 | byte[] cryptedResultInDb(String value) { 682 | MyBean bean = new MyBean(); 683 | bean.secretString = value; 684 | bean.nonSensitiveData = getClass().getSimpleName(); 685 | mongoTemplate.insert(bean); 686 | 687 | Document fromMongo = mongoTemplate.getCollection(MyBean.MONGO_MYBEAN).find(new Document("_id", new ObjectId(bean.id))).first(); 688 | Object cryptedSecret = fromMongo.get(MONGO_SECRETSTRING); 689 | assertThat(cryptedSecret).isInstanceOf(Binary.class); 690 | Object cryptedSecretData = ((Binary) cryptedSecret).getData(); 691 | assertThat(cryptedSecretData).isInstanceOf(byte[].class); 692 | return (byte[]) cryptedSecretData; 693 | } 694 | 695 | static void assertCryptException(Exception e, String collectionName, ObjectId objectId, String fieldName) { 696 | assertThat(e).isInstanceOf(DocumentCryptException.class); 697 | DocumentCryptException dce = (DocumentCryptException) e; 698 | assertThat(dce.getCollectionName()).isEqualTo(collectionName); 699 | if (objectId != null) assertThat(dce.getId()).isEqualTo(objectId); 700 | else assertThat(dce.getId()).isNotNull(); 701 | 702 | Throwable dceCause = dce.getCause(); 703 | assertThat(dceCause).isInstanceOf(FieldCryptException.class); 704 | FieldCryptException fce = (FieldCryptException) dceCause; 705 | assertThat(fce.getMessage()).isEqualTo(fieldName); 706 | 707 | Throwable fceCause = fce.getCause(); 708 | assertThat(fceCause).isInstanceOf(CryptOperationException.class); 709 | } 710 | } 711 | --------------------------------------------------------------------------------