BAEL-6046 - MongoDB - Field Level Encryption (#13266)

This commit is contained in:
Ulisses Lima 2023-01-13 04:53:50 -03:00 committed by GitHub
parent f6f63673a9
commit 1f987c4bb3
13 changed files with 590 additions and 1 deletions

View File

@ -24,6 +24,11 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId> <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-crypt</artifactId>
<version>${mongodb-crypt.version}</version>
</dependency>
<dependency> <dependency>
<groupId>de.flapdoodle.embed</groupId> <groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId> <artifactId>de.flapdoodle.embed.mongo</artifactId>
@ -31,4 +36,8 @@
</dependency> </dependency>
</dependencies> </dependencies>
<properties>
<mongodb-crypt.version>1.6.1</mongodb-crypt.version>
</properties>
</project> </project>

View File

@ -0,0 +1,12 @@
package com.baeldung.boot.csfle;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class MongoDbCsfleApplication {
public static void main(String... args) {
SpringApplication.run(MongoDbCsfleApplication.class, args);
}
}

View File

@ -0,0 +1,59 @@
package com.baeldung.boot.csfle.config;
import org.bson.BsonBinary;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import com.mongodb.client.vault.ClientEncryption;
@Configuration
public class EncryptionConfig {
@Value("${com.baeldung.csfle.master-key-path}")
private String masterKeyPath;
@Value("${com.baeldung.csfle.key-vault.namespace}")
private String keyVaultNamespace;
@Value("${com.baeldung.csfle.key-vault.alias}")
private String keyVaultAlias;
@Value("${com.baeldung.csfle.auto-decryption:false}")
private Boolean autoDecryption;
private ClientEncryption encryption;
private BsonBinary dataKeyId;
public void setEncryption(ClientEncryption encryption) {
this.encryption = encryption;
}
public ClientEncryption getEncryption() {
return encryption;
}
public void setDataKeyId(BsonBinary dataKeyId) {
this.dataKeyId = dataKeyId;
}
public BsonBinary getDataKeyId() {
return dataKeyId;
}
public String getKeyVaultNamespace() {
return keyVaultNamespace;
}
public String getKeyVaultAlias() {
return keyVaultAlias;
}
public String getMasterKeyPath() {
return masterKeyPath;
}
public Boolean getAutoDecryption() {
return autoDecryption;
}
}

View File

@ -0,0 +1,55 @@
package com.baeldung.boot.csfle.config;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.Map;
public class LocalKmsUtils {
private static final int KEY_SIZE = 96;
private LocalKmsUtils() {
}
public static byte[] createMasterKey(String path) throws FileNotFoundException, IOException {
byte[] masterKey = new byte[KEY_SIZE];
new SecureRandom().nextBytes(masterKey);
try (FileOutputStream stream = new FileOutputStream(path)) {
stream.write(masterKey);
}
return masterKey;
}
public static byte[] readMasterKey(String path) throws FileNotFoundException, IOException {
byte[] masterKey = new byte[KEY_SIZE];
try (FileInputStream stream = new FileInputStream(path)) {
stream.read(masterKey, 0, KEY_SIZE);
}
return masterKey;
}
public static Map<String, Map<String, Object>> providersMap(String masterKeyPath) throws FileNotFoundException, IOException {
if (masterKeyPath == null)
throw new IllegalArgumentException("master key path cannot be null");
File masterKeyFile = new File(masterKeyPath);
byte[] masterKey = masterKeyFile.isFile()
? readMasterKey(masterKeyPath)
: createMasterKey(masterKeyPath);
Map<String, Object> masterKeyMap = new HashMap<>();
masterKeyMap.put("key", masterKey);
Map<String, Map<String, Object>> providersMap = new HashMap<>();
providersMap.put("local", masterKeyMap);
return providersMap;
}
}

View File

@ -0,0 +1,122 @@
package com.baeldung.boot.csfle.config;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Arrays;
import java.util.Map;
import org.bson.BsonBinary;
import org.bson.BsonDocument;
import org.bson.Document;
import org.bson.conversions.Bson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
import org.springframework.data.mongodb.core.convert.MongoCustomConversions;
import com.baeldung.boot.csfle.config.converter.IntegerConverter;
import com.baeldung.boot.csfle.config.converter.StringConverter;
import com.mongodb.AutoEncryptionSettings;
import com.mongodb.ClientEncryptionSettings;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.MongoClientSettings.Builder;
import com.mongodb.MongoNamespace;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
import com.mongodb.client.MongoCollection;
import com.mongodb.client.model.Filters;
import com.mongodb.client.model.IndexOptions;
import com.mongodb.client.model.Indexes;
import com.mongodb.client.model.vault.DataKeyOptions;
import com.mongodb.client.vault.ClientEncryption;
import com.mongodb.client.vault.ClientEncryptions;
@Configuration
public class MongoClientConfig extends AbstractMongoClientConfiguration {
@Value("${spring.data.mongodb.uri}")
private String uri;
@Value("${spring.data.mongodb.database}")
private String db;
@Autowired
private EncryptionConfig encryptionConfig;
@Override
protected String getDatabaseName() {
return db;
}
@Override
public MongoCustomConversions customConversions() {
return new MongoCustomConversions(Arrays.asList(new StringConverter(encryptionConfig), new IntegerConverter(encryptionConfig)));
}
@Override
public MongoClient mongoClient() {
MongoClient client;
try {
client = MongoClients.create(clientSettings());
ClientEncryption encryption = createClientEncryption();
encryptionConfig.setDataKeyId(createOrRetrieveDataKey(client, encryption));
return client;
} catch (IOException e) {
throw new IllegalStateException("unable to create client", e);
}
}
private BsonBinary createOrRetrieveDataKey(MongoClient client, ClientEncryption encryption) {
MongoNamespace namespace = new MongoNamespace(encryptionConfig.getKeyVaultNamespace());
MongoCollection<Document> keyVault = client.getDatabase(namespace.getDatabaseName())
.getCollection(namespace.getCollectionName());
Bson query = Filters.in("keyAltNames", encryptionConfig.getKeyVaultAlias());
BsonDocument key = keyVault.withDocumentClass(BsonDocument.class)
.find(query)
.first();
if (key == null) {
keyVault.createIndex(Indexes.ascending("keyAltNames"), new IndexOptions().unique(true)
.partialFilterExpression(Filters.exists("keyAltNames")));
DataKeyOptions options = new DataKeyOptions();
options.keyAltNames(Arrays.asList(encryptionConfig.getKeyVaultAlias()));
return encryption.createDataKey("local", options);
} else {
return (BsonBinary) key.get("_id");
}
}
private ClientEncryption createClientEncryption() throws FileNotFoundException, IOException {
Map<String, Map<String, Object>> kmsProviders = LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath());
ClientEncryptionSettings encryptionSettings = ClientEncryptionSettings.builder()
.keyVaultMongoClientSettings(clientSettings())
.keyVaultNamespace(encryptionConfig.getKeyVaultNamespace())
.kmsProviders(kmsProviders)
.build();
encryptionConfig.setEncryption(ClientEncryptions.create(encryptionSettings));
return encryptionConfig.getEncryption();
}
private MongoClientSettings clientSettings() throws FileNotFoundException, IOException {
Builder settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(uri));
if (encryptionConfig.getAutoDecryption()) {
settings.autoEncryptionSettings(AutoEncryptionSettings.builder()
.keyVaultNamespace(encryptionConfig.getKeyVaultNamespace())
.kmsProviders(LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath()))
.bypassAutoEncryption(true)
.build());
}
return settings.build();
}
}

View File

@ -0,0 +1,27 @@
package com.baeldung.boot.csfle.config.converter;
import org.bson.BsonBinary;
import org.bson.BsonValue;
import org.bson.types.Binary;
import org.springframework.core.convert.converter.Converter;
import com.baeldung.boot.csfle.config.EncryptionConfig;
public class IntegerConverter implements Converter<Binary, Integer> {
private EncryptionConfig encryptionConfig;
public IntegerConverter(EncryptionConfig config) {
this.encryptionConfig = config;
}
@Override
public Integer convert(Binary source) {
BsonBinary bin = new BsonBinary(source.getType(), source.getData());
BsonValue value = encryptionConfig.getEncryption()
.decrypt(bin);
return value.asInt32()
.getValue();
}
}

View File

@ -0,0 +1,27 @@
package com.baeldung.boot.csfle.config.converter;
import org.bson.BsonBinary;
import org.bson.BsonValue;
import org.bson.types.Binary;
import org.springframework.core.convert.converter.Converter;
import com.baeldung.boot.csfle.config.EncryptionConfig;
public class StringConverter implements Converter<Binary, String> {
private EncryptionConfig encryptionConfig;
public StringConverter(EncryptionConfig config) {
this.encryptionConfig = config;
}
@Override
public String convert(Binary source) {
BsonBinary bin = new BsonBinary(source.getType(), source.getData());
BsonValue value = encryptionConfig.getEncryption()
.decrypt(bin);
return value.asString()
.getValue();
}
}

View File

@ -0,0 +1,47 @@
package com.baeldung.boot.csfle.data;
import org.springframework.data.mongodb.core.mapping.Document;
@Document("citizens")
public class Citizen {
private String name;
private String email;
private Integer birthYear;
public Citizen() {
}
public Citizen(EncryptedCitizen encryptedCitizen) {
this.name = encryptedCitizen.getName();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Integer getBirthYear() {
return birthYear;
}
public void setBirthYear(Integer birthYear) {
this.birthYear = birthYear;
}
@Override
public String toString() {
return "Citizen [name=" + name + ", email=" + email + ", birthYear=" + birthYear + "]";
}
}

View File

@ -0,0 +1,48 @@
package com.baeldung.boot.csfle.data;
import org.bson.BsonBinary;
import org.springframework.data.mongodb.core.mapping.Document;
@Document("citizens")
public class EncryptedCitizen {
private String name;
private BsonBinary email;
private BsonBinary birthYear;
public EncryptedCitizen() {
}
public EncryptedCitizen(Citizen citizen) {
this.name = citizen.getName();
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public BsonBinary getEmail() {
return email;
}
public void setEmail(BsonBinary email) {
this.email = email;
}
public BsonBinary getBirthYear() {
return birthYear;
}
public void setBirthYear(BsonBinary birthYear) {
this.birthYear = birthYear;
}
@Override
public String toString() {
return "Citizen [name=" + name + ", email=" + email + ", birthYear=" + birthYear + "]";
}
}

View File

@ -0,0 +1,63 @@
package com.baeldung.boot.csfle.service;
import java.util.List;
import org.bson.BsonBinary;
import org.bson.BsonInt32;
import org.bson.BsonString;
import org.bson.BsonValue;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.stereotype.Service;
import com.baeldung.boot.csfle.config.EncryptionConfig;
import com.baeldung.boot.csfle.data.Citizen;
import com.baeldung.boot.csfle.data.EncryptedCitizen;
import com.mongodb.client.model.vault.EncryptOptions;
@Service
public class CitizenService {
public static final String DETERMINISTIC_ALGORITHM = "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic";
public static final String RANDOM_ALGORITHM = "AEAD_AES_256_CBC_HMAC_SHA_512-Random";
@Autowired
private MongoTemplate mongo;
@Autowired
private EncryptionConfig encryptionConfig;
public EncryptedCitizen save(Citizen citizen) {
EncryptedCitizen encryptedCitizen = new EncryptedCitizen(citizen);
encryptedCitizen.setEmail(encrypt(citizen.getEmail(), DETERMINISTIC_ALGORITHM));
encryptedCitizen.setBirthYear(encrypt(citizen.getBirthYear(), RANDOM_ALGORITHM));
return mongo.save(encryptedCitizen);
}
public List<Citizen> findAll() {
return mongo.findAll(Citizen.class);
}
public Citizen findByEmail(String email) {
Query byEmail = new Query(Criteria.where("email")
.is(encrypt(email, DETERMINISTIC_ALGORITHM)));
return mongo.findOne(byEmail, Citizen.class);
}
public BsonBinary encrypt(Object value, String algorithm) {
if (value == null)
return null;
BsonValue bsonValue = value instanceof Integer
? new BsonInt32((Integer) value)
: new BsonString(value.toString());
EncryptOptions options = new EncryptOptions(algorithm);
options.keyId(encryptionConfig.getDataKeyId());
return encryptionConfig.getEncryption()
.encrypt(bsonValue, options);
}
}

View File

@ -0,0 +1,38 @@
package com.baeldung.boot.csfle.web;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.baeldung.boot.csfle.data.Citizen;
import com.baeldung.boot.csfle.data.EncryptedCitizen;
import com.baeldung.boot.csfle.service.CitizenService;
@RestController
@RequestMapping("/citizen")
public class CitizenController {
@Autowired
private CitizenService service;
@GetMapping
public List<Citizen> get() {
return service.findAll();
}
@GetMapping("by")
public Citizen getBy(@RequestParam String email) {
return service.findByEmail(email);
}
@PostMapping
public EncryptedCitizen post(@RequestBody Citizen citizen) {
return service.save(citizen);
}
}

View File

@ -0,0 +1,73 @@
package com.baeldung.boot.csfle;
import static org.junit.jupiter.api.Assertions.*;
import org.bson.BsonBinary;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit4.SpringRunner;
import com.baeldung.boot.csfle.data.Citizen;
import com.baeldung.boot.csfle.data.EncryptedCitizen;
import com.baeldung.boot.csfle.service.CitizenService;
@DirtiesContext
@RunWith(SpringRunner.class)
@TestPropertySource("/embedded.properties")
@SpringBootTest(classes = MongoDbCsfleApplication.class)
public class CitizenServiceLiveTest {
@Autowired
private MongoTemplate mongo;
@Autowired
private CitizenService service;
@Test
public void givenCitizen_whenEncryptingEmail_thenEncryptedCitizenEmailMatches() {
final Citizen citizen = new Citizen();
citizen.setName("Foo");
citizen.setEmail("foo@citizen.com");
BsonBinary encryptedEmail = service.encrypt(citizen.getEmail(), CitizenService.DETERMINISTIC_ALGORITHM);
EncryptedCitizen saved = service.save(citizen);
assertEquals(encryptedEmail, saved.getEmail());
}
@Test
public void givenRandomEncryptedField_whenFilteringByField_thenDocumentNotFound() {
Citizen john = new Citizen();
john.setName("Jane Doe");
john.setEmail("jane.doe@citizen.com");
john.setBirthYear(1852);
service.save(john);
Query byBirthYear = new Query(Criteria.where("birthYear")
.is(service.encrypt(john.getBirthYear(), CitizenService.RANDOM_ALGORITHM)));
Citizen result = mongo.findOne(byBirthYear, Citizen.class);
assertNull(result);
}
@Test
public void givenDeterministicallyEncryptedField_whenFilteringByField_thenDocumentFound() {
Citizen jane = new Citizen();
jane.setName("Jane Doe");
jane.setEmail("jane.doe@citizen.com");
jane.setBirthYear(1952);
service.save(jane);
Citizen result = service.findByEmail(jane.getEmail());
assertNotNull(result);
}
}

View File

@ -1 +1,10 @@
spring.mongodb.embedded.version=4.4.9 spring.mongodb.embedded.version=4.4.9
spring.data.mongodb.uri=changeit
spring.data.mongodb.database=changeit
com.baeldung.csfle.kms-provider=local
com.baeldung.csfle.key-vault.namespace=encryption._keyVault
com.baeldung.csfle.key-vault.alias=master.key
com.baeldung.csfle.master-key-path=/tmp/master.key
com.baeldung.csfle.auto-decryption=false