MongoDB 字段级加密

1、简介

本文将带你了解如何使用 MongoDB 的客户端字段级加密(或 CSFLE)来加密文档中的指定字段,主要介绍显式/自动加密和显式/自动解密,以及加密算法之间的差异。

2、场景与设置

MongoDB AtlasMongoDB 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 中加入一些配置。只有当我们指定 autoEncryptiontrue 时,才需要使用 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-syncmongodb-driver-corebson

<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” 集合并定义要加密的字段。为此,我们将定义一些关键对象: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);

我们使用密钥 ID 为所有加密属性设置 encryptMetadata,因此不需要为每个属性定义它。对于 properties,我们定义了 emailbirthYear,并指定了它们的 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