1. 引言

本文将深入探讨 MongoDB 的客户端字段级加密(CSFLE)技术,演示如何对文档中的特定字段进行加密。我们将覆盖显式/自动加密与显式/自动解密,并重点分析不同加密算法的差异。

最终我们将构建一个简单应用,实现包含加密与非加密字段的文档插入和检索功能。

2. 场景与准备

MongoDB Atlas 和 MongoDB Enterprise 都支持自动加密。MongoDB Atlas 提供永久免费集群,可用于测试所有功能。

⚠️ 重要提示:字段级加密不同于静态存储加密(后者加密整个数据库或磁盘)。通过选择性加密特定字段,我们能在保护敏感数据的同时保持高效的查询和索引性能。我们将基于Spring BootSpring 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());
}

现在仅当未启用自动加密时才设置 bypassAutoEncryptiontrue。接下来定义额外选项和模式映射:

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" 集合定义要加密的字段。定义关键对象:encryptMetadataproperties

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 中定义 emailbirthYear,指定其 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获取。


原始标题:MongoDB – Field Level Encryption