1. 引言
本文将深入探讨 MongoDB 的客户端字段级加密(CSFLE)技术,演示如何对文档中的特定字段进行加密。我们将覆盖显式/自动加密与显式/自动解密,并重点分析不同加密算法的差异。
最终我们将构建一个简单应用,实现包含加密与非加密字段的文档插入和检索功能。
2. 场景与准备
MongoDB Atlas 和 MongoDB Enterprise 都支持自动加密。MongoDB Atlas 提供永久免费集群,可用于测试所有功能。
⚠️ 重要提示:字段级加密不同于静态存储加密(后者加密整个数据库或磁盘)。通过选择性加密特定字段,我们能在保护敏感数据的同时保持高效的查询和索引性能。我们将基于Spring Boot和Spring Data MongoDB构建基础应用。
首先创建包含加密和非加密字段的文档类。从手动加密开始,再演示如何通过自动加密实现相同功能。手动加密需要中间对象表示加密后的 POJO,并需为每个字段创建加密/解密方法。
2.1. Spring Boot 启动器与加密依赖
首先添加 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. 创建主密钥
主密钥用于唯一加密和解密数据,任何持有者都能读取数据,因此必须严格保管。
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字节**。我们创建的本地密钥库仅用于演示,用随机字节填充。
该密钥只需生成一次,因此添加方法检索已存在的密钥:
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要求:
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;
}
支持多密钥管理,但本教程仅使用一个。
2.3. 自定义配置
为简化配置,创建几个自定义属性。通过配置类保存这些属性及加密所需对象:
@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;
// getters
密钥库是加密密钥的集合。命名空间由数据库名和集合名组成,别名是后续检索密钥库的简单名称。
最后添加属性保存加密密钥 ID:
private BsonBinary dataKeyId;
// getters and setters
该属性将在 MongoDB 客户端配置时填充。
3. 创建 MongoClient 与加密对象
创建加密所需对象和设置,通过自定义 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() 的映射:
@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() 生成密钥;否则获取其 “_id” 字段。即使使用单密钥,也为 keyAltNames 字段创建唯一索引,避免重复别名风险。使用 createIndex() 和部分筛选表达式(因该字段非必需):
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 不再使用时需关闭。此处仅需一次创建索引,故使用try-with-resources 块确保用后立即关闭。
3.2. 组合创建客户端
最后重写 mongoClient() 创建客户端和加密对象,将数据密钥 ID 存入 encryptionConfig:
@Bean
@Override
public MongoClient mongoClient() {
ClientEncryption encryption = clientEncryption();
encryptionConfig.setDataKeyId(createOrRetrieveDataKey(encryption));
return MongoClients.create(clientSettings());
}
完成所有配置后,即可开始加密字段。
4. 字段加密服务
创建服务类保存和检索含加密字段的文档。但加密前需先定义文档。
4.1. 文档类
首先创建包含基本属性的类:
@Document("citizens")
public class Citizen {
private String name;
private String email;
private Integer birthYear;
// getters and setters
}
然后创建同类型但含二进制属性的版本。因使用显式加密,此类用于保存加密数据:
@Document("citizens")
public class EncryptedCitizen {
private String name;
private Binary email;
private Binary birthYear;
// getters and setters
}
4.2. 初始化服务
服务类包含加密所需的所有配置:算法类型、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. 保存文档
在服务类添加保存文档方法。对 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()*。必须使用接收二进制子类型的 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 客户端支持配置自动解密**。需配置客户端的自动加密设置(接收主密钥配置)以启用该功能。回到 EncryptionConfig 添加新配置属性:
@Value("${com.baeldung.csfle.auto-decryption:false}")
private boolean autoDecryption;
// default 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;
// default getters
添加辅助方法将数据密钥转为 UUID String,后续配置客户端需要:
public String dataKeyIdUuid() {
if (dataKeyId == null)
throw new IllegalStateException("data key not initialized");
return dataKeyId.asUuid()
.toString();
}
7.1. 更新驱动依赖
使用 cryptSharedLibPath 驱动选项时,需确保使用最新版本的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。接下来定义额外选项和模式映射:
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. 加密模式
自动加密需为每个要加密的集合提供加密模式映射。下一步为 "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);
为所有加密属性设置 encryptMetadata 的密钥 ID,避免在每个属性定义中重复。在 properties 中定义 email 和 birthYear,指定其 bsonType 和加密 algorithm。
7.4. 简化 CitizenService 工作
启用自动加密后,不再需要显式加密。重构 CitizenService 考虑配置,从 save() 方法开始:
public void save(Citizen citizen) {
if (encryptionConfig.isAutoEncryption()) {
mongo.save(citizen);
} else {
// same as before
}
}
注意手动加密的回退仅用于演示,生产应用无需此回退。
**对于 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 功能原理、配置方式及加密解密过程中的关键类。
我们对比了随机与确定性加密算法的差异,最后配置客户端实现字段的自动加密解密。
完整源代码可在GitHub获取。