MongoDB – 字段級加密
一、簡介
在本教程中,我們將使用 MongoDB 的客戶端字段級加密(CSFLE)來加密文檔中的選定字段。我們將介紹顯式/自動加密和顯式/自動解密,重點介紹加密算法之間的差異。
最終,我們將擁有一個簡單的應用程序,可以插入和檢索包含加密和未加密字段混合的文檔。
2. 場景和設置
MongoDB Atlas 和 MongoDB Enterprise 都支持自動加密。 MongoDB Atlas 有一個永久免費的集群,我們可以用它來測試所有功能。
此外,值得注意的是,字段級加密與靜態存儲不同,靜態存儲對整個數據庫或磁盤進行加密。通過有選擇地加密特定字段,我們可以更好地保護敏感數據,同時允許高效的查詢和索引。因此,我們將從一個簡單的 Spring Boot 應用程序開始,使用 Spring Data MongoDB 插入和檢索數據。
首先,我們將創建一個包含未加密和加密字段混合的文檔類。我們將從手動加密開始,然後看看如何通過自動加密來實現相同的目的。對於手動加密,我們需要一個中間對象來表示加密的 POJO,並且我們將創建方法來加密/解密每個字段。
2.1. Spring Boot 啟動器和加密依賴項
首先,要連接到 MongoDB,我們需要spring-boot-starter-data-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.初始化服務
我們的服務類包含加密所需的所有配置。 算法 types 、 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);
}
請注意,空值未加密。如果我們的對像中有空字段值,它們將不會出現在我們的文檔中。
4.3.保存文檔
最後,讓我們在服務類中添加一個方法來保存文檔。同樣,我們將對電子郵件使用確定性算法,對出生年份使用隨機算法:
public void save(Citizen citizen) {
EncryptedCitizen encryptedCitizen = new EncryptedCitizen();
<span class="pl-s1">encryptedCitizen</span>.<span class="pl-en">setName</span>(<span class="pl-s1">citizen</span>.<span class="pl-en">getName</span>());
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) {
<span class="pl-smi"> Objects</span>.<span class="pl-en">requireNonNull</span>(<span class="pl-s1">value</span>);
return clientEncryption.decrypt(
new BsonBinary(value.getType(), value.getData()));
}
然後,我們將在解密EncryptedCitizen
實例的方法中使用它:
private Citizen decrypt(EncryptedCitizen encrypted) {
Objects.requireNonNull(encrypted);
Citizen citizen = new Citizen();
<span class="pl-s1">citizen</span>.<span class="pl-en">setName</span>(<span class="pl-s1">encrypted</span>.<span class="pl-en">getName</span>());
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
中添加一個通過電子郵件查詢的方法:
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
中包含一些配置。僅當我們將autoEncryptionLib
指定為 true 時才需要autoEncryption
,因此我們使用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 ,](https://search.maven.org/search?q=g:org.mongodb%20AND%20a: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.加密模式
為了使自動加密發揮作用,我們必須為每個要加密的集合提供一個加密模式映射。因此,我們的下一步是使用我們的“公民”集合併定義我們想要加密的字段。為此,我們將定義一些關鍵對象: 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
的工作
現在我們已經啟用了自動加密,我們不再需要顯式加密。讓我們重構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);
}
}
八、結論
在本文中,我們了解了 MongoDB 的 CSFLE 功能如何工作、如何配置以及加密和解密過程中涉及的類。
此外,我們還看到了隨機加密算法和確定性加密算法之間的差異。最後,我們將客戶端配置為自動加密和解密字段。
與往常一樣,源代碼可以在 GitHub 上獲取。