MongoDB 字段级加密
1、简介
本文将带你了解如何使用 MongoDB 的客户端字段级加密(或 CSFLE)来加密文档中的指定字段,主要介绍显式/自动加密和显式/自动解密,以及加密算法之间的差异。
2、场景与设置
MongoDB Atlas 和 MongoDB Enterprise 都支持自动加密。MongoDB Atlas 有一个 永久免费的集群,我们可以用它来测试所有功能。
此外,需要注意的是,字段级加密有别于静态存储,后者会对整个数据库或磁盘进行加密。通过有选择地加密特定字段,我们可以更好地保护敏感数据,同时实现高效查询和索引。
本文将从一个简单的 Spring Boot 应用开始,使用 Spring Data MongoDB 插入和检索数据。
首先,我们要创建一个包含未加密字段和加密字段混合的文档类。我们将从手动加密开始,然后看看如何通过 自动加密 实现同样的效果。对于手动加密,我们需要一个中间对象来表示加密的 POJO,并创建方法来加密/解密每个字段。
2.1. Spring Boot Starter 和加解密模块
首先,需要使用 spring-boot-starter-data-mongodb 来连接到 MongoDB:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
然后,还需要 mongodb-crypt,以启用加密功能:
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-crypt</artifactId>
<version>1.7.3</version>
</dependency>
由于我们使用的是 Spring Boot,所以只需上面两个依赖就足够了。
2.2、创建 Master Key
Master Key(主密钥)用于加密和解密数据。任何拥有它的人都可以读取我们的数据。因此,确保主密钥的安全至关重要。
MongoDB 建议使用 远程密钥管理服务。不过,为了简单起见,本文使用本地密钥管理器,如下:
public class LocalKmsUtils {
public static byte[] createMasterKey(String path) {
byte[] masterKey = new byte[96];
new SecureRandom().nextBytes(masterKey);
try (FileOutputStream stream = new FileOutputStream(path)) {
stream.write(masterKey);
}
return masterKey;
}
// ...
}
本地密钥的唯一要求是长度为 96 字节。如上,createMasterKey
会创建一个本地文件来存储密钥,这只是为了演示而使用,我们用随机字节填充它。
这个密钥只需要生成一次,所以还要创建一个方法,以读取到创建好的密钥:
public static byte[] readMasterKey(String path) {
byte[] masterKey = new byte[96];
try (FileInputStream stream = new FileInputStream(path)) {
stream.read(masterKey, 0, 96);
}
return masterKey;
}
最后,创建一个方法,以 ClientEncryptionSettings
(在下文中创建)所需的格式返回包含主密钥的 Map
:
public static Map<String, Map<String, Object>> providersMap(String masterKeyPath) {
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;
}
它支持使用多个 Key,但在本文中我们只使用一个。
2.3、自定义配置
为了方便配置,我们先创建几个自定义属性。然后,使用一个配置类来保存这些属性和一些加密所需的对象。
从本地主密钥路径的配置开始:
@Configuration
public class EncryptionConfig {
@Value("${com.baeldung.csfle.master-key-path}")
private String masterKeyPath;
// ...
}
然后,加入密钥库(Key Vault)的配置:
@Value("${com.baeldung.csfle.key-vault.namespace}")
private String keyVaultNamespace;
@Value("${com.baeldung.csfle.key-vault.alias}")
private String keyVaultAlias;
// Getter 方法省略
密钥库是加密密钥的集合。因此,命名空间结合了数据库和集合名称。别名是一个简单的名称,用于以后检索密钥库。
最后,创建一个属性来保存加密密钥 ID:
private BsonBinary dataKeyId;
// Gettr / Setter 方法省略
稍后,当我们配置 MongoDB 客户端时,这些属性会被自动注入。
3、创建 MongoClient 和 Encryption 对象
为了创建加密所需的对象和设置,让我们创建一个自定义的 MongoDB 客户端,这样我们就能更好地控制其配置。
首先,定义配置类,继承 AbstractMongoClientConfiguration
。然后添加创建连接所需的常规参数,并注入我们的 encryptionConfig
:
@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;
}
// ...
}
接下来,创建一个方法,返回创建客户端和 ClientEncryption
对象所需的 MongoClientSettings
对象。我们使用连接 uri
变量:
private MongoClientSettings clientSettings() {
return MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(uri))
.build();
}
然后,创建 ClientEncryption
Bean,负责生成数据密钥和加密操作。它由一个 ClientEncryptionSettings
对象构建,该对象接收我们的 clientSettings()
、来自 EncryptionConfig
的密钥库命名空间,以及我们的 providersMap()
方法返回的 Map
。
@Bean
public ClientEncryption clientEncryption() {
ClientEncryptionSettings encryptionSettings = ClientEncryptionSettings.builder()
.keyVaultMongoClientSettings(clientSettings())
.keyVaultNamespace(encryptionConfig.getKeyVaultNamespace())
.kmsProviders(LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath()))
.build();
return ClientEncryptions.create(encryptionSettings);
}
最后,返回它,以便以后在构建数据密钥时使用。
3.1、创建数据密钥
在创建 MongoDB 客户端之前的最后一步是创建一个方法,如果数据密钥不存在则生成它。接下来,我们将接收 ClientEncryption
对象,通过别名获取密钥库文档的引用。
private BsonBinary createOrRetrieveDataKey(ClientEncryption encryption) {
BsonDocument key = encryption.getKeyByAltName(encryptionConfig.getKeyVaultAlias());
if (key == null) {
createKeyUniqueIndex();
DataKeyOptions options = new DataKeyOptions();
options.keyAltNames(Arrays.asList(encryptionConfig.getKeyVaultAlias()));
return encryption.createDataKey("local", options);
} else {
return (BsonBinary) key.get("_id");
}
}
如果没有返回结果,就通过 createDataKey()
生成 Key,并传递别名配置。否则,获取其 _id 字段。此外,尽管我们使用的是单一 Key,但我们还是为 keyAltNames 字段创建了唯一索引,这样就不会有创建重复别名的风险。使用 createIndex()
和 部分过滤表达式(Partial Filter expression) 来创建索引,因为这个字段不是必需的:
private void createKeyUniqueIndex() {
try (MongoClient client = MongoClients.create(clientSettings()) {
MongoNamespace namespace = new MongoNamespace(encryptionConfig.getKeyVaultNamespace());
MongoCollection<Document> keyVault = client.getDatabase(namespace.getDatabaseName())
.getCollection(namespace.getCollectionName());
keyVault.createIndex(Indexes.ascending("keyAltNames"), new IndexOptions().unique(true)
.partialFilterExpression(Filters.exists("keyAltNames")));
}
}
需要注意的是,MongoClient
在不再使用时需要关闭。由于我们只需要使用 MongoClient
创建一次索引,因此我们在 try-with-resources
代码块中使用它,这样它就会在使用后立即关闭。
3.2、整合起来,创建客户端
最后,覆写 mongoClient()
来创建客户端和加密对象,然后在 encryptionConfig
中存储数据密钥 ID。
@Bean
@Override
public MongoClient mongoClient() {
ClientEncryption encryption = clientEncryption();
encryptionConfig.setDataKeyId(createOrRetrieveDataKey(encryption));
return MongoClients.create(clientSettings());
}
完成所有设置后,我们就可以对一些字段进行加密了。
4、字段加密 Service
创建一个 Service 类,用于保存和检索带有加密字段的文档。但在加密之前,我们需要定义文档。
4.1、文档类
先用一个类来保存一些基本属性:
@Document("citizens")
public class Citizen {
private String name;
private String email;
private Integer birthYear;
// Getter / Setter 省略
}
然后,我们还需要一个具有二进制属性的相同类型的版本。由于我们正在进行显式加密,因此将使用该类来保存加密数据:
@Document("citizens")
public class EncryptedCitizen {
private String name;
private Binary email;
private Binary birthYear;
// Getter / Setter 省略
}
4.2、初始化 Service
我们的 Service
类包含加密所需的所有配置。算法类型、ClientEncryption
Bean 和 EncryptionConfig
。此外,它还引用了 MongoTemplate
,用于保存和获取文档:
@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";
private final MongoTemplate mongo;
private final EncryptionConfig encryptionConfig;
private final ClientEncryption clientEncryption;
public CitizenService(
MongoTemplate mongo, EncryptionConfig encryptionConfig, ClientEncryption clientEncryption) {
this.mongo = mongo;
this.encryptionConfig = encryptionConfig;
this.clientEncryption = clientEncryption;
}
// ...
}
MongoDB 允许使用两种加密算法:确定性算法和随机算法。确定性算法将始终产生相同的加密值,而随机算法则不会。这使得随机算法更安全,但也意味着用它加密的字段不能被轻易查询。这是因为我们必须在查询前对值进行加密。另一方面,在解密时,所选算法并不重要。
现在,添加一个加密值的方法:
public Binary encrypt(BsonValue bsonValue, String algorithm) {
Objects.requireNonNull(bsonValue);
Objects.requireNonNull(algorithm);
EncryptOptions options = new EncryptOptions(algorithm);
options.keyId(encryptionConfig.getDataKeyId());
BsonBinary encryptedValue = clientEncryption.encrypt(bsonValue, options);
return new Binary(encryptedValue.getType(), encryptedValue.getData());
}
该方法使用从我们的配置中传递的算法和数据密钥,并返回一个与 EncryptedCitizen
兼容的类型。此外,还要为需要的类型添加几个工具方法:
Binary encrypt(String value, String algorithm) {
Objects.requireNonNull(value);
Objects.requireNonNull(algorithm);
return encrypt(new BsonString(value), algorithm);
}
Binary encrypt(Integer value, String algorithm) {
Objects.requireNonNull(value);
Objects.requireNonNull(algorithm);
return encrypt(new BsonInt32(value), algorithm);
}
注意,null
值不会被加密。如果对象中存在 null
字段值,它们将不会出现在文档中。
4.3、保存文档
最后,在 Service
类中添加一个保存文档的方法。同样,我们对 email
使用确定性算法,对 birthYear
使用随机算法:
public void save(Citizen citizen) {
EncryptedCitizen encryptedCitizen = new EncryptedCitizen();
encryptedCitizen.setName(citizen.getName());
if (citizen.getEmail() != null) {
encryptedCitizen.setEmail(encrypt(citizen.getEmail(), DETERMINISTIC_ALGORITHM));
} else {
encryptedCitizen.setEmail(null);
}
if (citizen.getBirthYear() != null) {
encryptedCitizen.setBirthYear(encrypt(citizen.getBirthYear(), RANDOM_ALGORITHM));
} else {
encryptedCitizen.setBirthYear(null);
}
mongo.save(encryptedCitizen);
}
现在,我们可以使用 mongo.findAll(EncryptedCitizen.class)
获取文档。但加密字段将不可读。
5、解密字段
要解密字段,需要为每个要解密的字段调用 ClientEncryption.decrypt()
。该方法接收加密后的 BsonBinary
,并返回解密后的 BsonValue
。
从解密 Binary
值的方法开始,将其转换为 BsonBinary
然后传递给 ClientEncryption.decrypt()
。重要的是要使用接收 Binary
子类型的 BsonBinary
构造函数;否则,可能会遇到 MongoCryptException
异常:
public BsonValue decryptProperty(Binary value) {
Objects.requireNonNull(value);
return clientEncryption.decrypt(
new BsonBinary(value.getType(), value.getData()));
}
然后,在解密 EncryptedCitizen
实例的方法中使用它:
private Citizen decrypt(EncryptedCitizen encrypted) {
Objects.requireNonNull(encrypted);
Citizen citizen = new Citizen();
citizen.setName(encrypted.getName());
BsonValue decryptedBirthYear = encrypted.getBirthYear() != null
? decryptProperty(encrypted.getBirthYear())
: null;
if (decryptedBirthYear != null) {
citizen.setBirthYear(decryptedBirthYear.asInt32()
.intValue());
}
BsonValue decryptedEmail = encrypted.getEmail() != null
? decryptProperty(encrypted.getEmail())
: null;
if (decryptedEmail != null) {
citizen.setEmail(decryptedEmail.asString()
.getValue());
}
return citizen;
}
最后,整合所有,创建一个 findAll()
实现,对从数据库接收到的数据进行解密:
public List<Citizen> findAll() {
List<EncryptedCitizen> allEncrypted = mongo.findAll(EncryptedCitizen.class);
return allEncrypted.stream()
.map(this::decrypt)
.collect(Collectors.toList());
}
5.1、配置自动解密
此外,MongoDB 客户端允许我们配置 自动解密。这必须要配置客户端的 auto-decryption 属性以启用此功能,该设置接收我们的主密钥配置。
回到 EncryptionConfig
,创建一个新的配置属性:
@Value("${com.baeldung.csfle.auto-decryption:false}")
private boolean autoDecryption;
// Getter 方法省略
我们将默认值设置为 false
,因此该属性不是必需的。然后,在 MongoClientConfig
中,重构 clientSettings()
以检查是否启用了自动解密,并创建 AutoEncryptionSettings
:
MongoClientSettings clientSettings() {
Builder settings = MongoClientSettings.builder()
.applyConnectionString(new ConnectionString(uri));
if (encryptionConfig.isAutoDecryption()) {
settings.autoEncryptionSettings(
AutoEncryptionSettings.builder()
.keyVaultNamespace(encryptionConfig.getKeyVaultNamespace())
.kmsProviders(LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath()))
.bypassAutoEncryption(true)
.build());
}
return settings.build();
}
最重要的是,要设置 bypassAutoEncryption(true)
。这是必须的,因为到目前为止只配置了自动解密。
如上,就是我们需要的所有配置。启用此功能后,解密将由 MongoDB 客户端完成。
6、查询已加密的字段
为了在查询时通过加密字段进行过滤,如果我们只有未加密的值,就必须在执行查询之前加密我们想要的值。
例如,在 CitizenService
中添加一个按 email
查询的方法:
Citizen findByEmail(String email) {
Query byEmail = new Query(Criteria.where("email")
.is(encrypt(email, DETERMINISTIC_ALGORITHM)));
return mongo.findOne(byEmail, Citizen.class);
}
只要使用确定性算法保存字段,它就会返回预期的文档。
7、自动加密
通过在 MongoClient
中指定 cryptSharedLibPath
,可以配置自动加密。
在 EncryptionConfig
中加入一些配置。只有当我们指定 autoEncryption
为 true
时,才需要使用 autoEncryptionLib
,因此我们使用 null
作为默认值:
@Value("${com.baeldung.csfle.auto-encryption:false}")
private boolean autoEncryption;
@Value("${com.baeldung.csfle.auto-encryption-lib:#{null}}")
private File autoEncryptionLib;
// Getter 方法省略
此外,添加一个工具方法,以 UUID 字符串的形式获取数据密钥。稍后我们将需要它来配置客户端:
public String dataKeyIdUuid() {
if (dataKeyId == null)
throw new IllegalStateException("data key not initialized");
return dataKeyId.asUuid()
.toString();
}
7.1、更新驱动依赖
要使用 cryptSharedLibPath
driver 选项,我们还必须确保使用的是最新版本的 mongodb-driver-sync、mongodb-driver-core 和 bson:
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-sync</artifactId>
<version>4.9.1</version>
</dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>mongodb-driver-core</artifactId>
<version>4.9.1</version>
</dependency>
<dependency>
<groupId>org.mongodb</groupId>
<artifactId>bson</artifactId>
<version>4.9.1</version>
</dependency>
7.2、重构 MongoClientConfig
autoEncryptionLib
指向 crypt_shared
库文件,使用该功能前必须 下载 该文件。
重构 MongoClientConfig
中的 clientSettings()
,检查是否启用了该选项,我们已经有了数据密钥,而且自动加密库是一个实际文件:
if (encryptionConfig.isAutoDecryption()) {
AutoEncryptionSettings.Builder builder = AutoEncryptionSettings.builder()
.keyVaultNamespace(encryptionConfig.getKeyVaultNamespace())
.kmsProviders(LocalKmsUtils.providersMap(encryptionConfig.getMasterKeyPath()));
if (encryptionConfig.isAutoEncryption() && encryptionConfig.getDataKeyId() != null) {
File autoEncryptionLib = encryptionConfig.getAutoEncryptionLib();
if (!autoEncryptionLib.isFile()) {
throw new IllegalArgumentException("encryption lib must be an existing file");
}
// ...
} else {
builder.bypassAutoEncryption(true);
}
settings.autoEncryptionSettings(builder.build());
}
现在,我们只需在未启用自动加密时将 bypassAutoEncryption
设为 true
。
接下来,需要定义额外选项和 schema map:
Map<String, Object> map = new HashMap<>();
map.put("cryptSharedLibRequired", true);
map.put("cryptSharedLibPath", autoEncryptionLib.toString());
builder.extraOptions(map);
cryptSharedLibRequired
选项将强制 crypt_shared
被正确配置,而不是尝试在没有配置的情况下生成 mongocryptd
。
使用 crypt_shared
是首选的,因为我们不需要在我们的机器上运行额外的服务。
7.3、Encryption Schema
要想实现自动加密,我们必须为每个要加密的集合提供一个 Encryption Schema Map。因此,我们的下一步是使用 “citizens” 集合并定义要加密的字段。为此,我们将定义一些关键对象:encryptMetadata
和 properties
:
String keyUuid = encryptionConfig.dataKeyIdUuid();
HashMap<String, BsonDocument> schemaMap = new HashMap<>();
schemaMap.put(getDatabaseName() + ".citizens", BsonDocument.parse("{"
+ " bsonType: \"object\","
+ " encryptMetadata: {"
+ " keyId: [UUID(\"" + keyUuid + "\")]"
+ " },"
+ " properties: {"
+ " email: {"
+ " encrypt: {"
+ " bsonType: \"string\","
+ " algorithm: \"AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic\""
+ " }"
+ " },"
+ " birthYear: {"
+ " encrypt: {"
+ " bsonType: \"int\","
+ " algorithm: \"AEAD_AES_256_CBC_HMAC_SHA_512-Random\""
+ " }"
+ " }"
+ " }"
+ "}"));
builder.schemaMap(schemaMap);
我们使用密钥 ID 为所有加密属性设置 encryptMetadata
,因此不需要为每个属性定义它。对于 properties
,我们定义了 email
和 birthYear
,并指定了它们的 bsonType
和加密算法(algorithm
)。
7.4、简化 CitizenService
既然我们已经启用了自动加密,就不再需要显式加密了。
从 save()
方法开始重构 CitizenService
,将我们的配置考虑在内:
public void save(Citizen citizen) {
if (encryptionConfig.isAutoEncryption()) {
mongo.save(citizen);
} else {
// 和之前一样
}
}
注意,这里仅为演示目的提供手动加密的备用方案。生产应用程序不需要这样的备用方案。
然后,对于 findByEmail()
,如果我们开启了自动加密,就不需要再手动加密 email
的值了:
public Citizen findByEmail(String email) {
Criteria emailCriteria = Criteria.where("email");
if (encryptionConfig.isAutoEncryption()) {
emailCriteria.is(email);
} else {
emailCriteria
.is(encrypt(email, DETERMINISTIC_ALGORITHM));
}
Query byEmail = new Query(emailCriteria);
if (encryptionConfig.isAutoDecryption()) {
return mongo.findOne(byEmail, Citizen.class);
} else {
EncryptedCitizen encryptedCitizen = mongo.findOne(byEmail, EncryptedCitizen.class);
return decrypt(encryptedCitizen);
}
}
8、总结
本文介绍了如何使用 MongoDB 的 CSFLE 功能来加密文档中的指定字段、以及如何配置在加密和解密过程中涉及的类
Ref:https://www.baeldung.com/mongodb-field-level-encryption