Node.js 結合 MongoDB 實現欄位級自動加密

語言: CN / TW / HK

某些場景下,對於資料隱私會有較高的要求,例如,使用者系統的個人資訊(身份證、手機號)、醫患系統的患者資訊等, 怎麼用技術手段安全的保護這些敏感資料是我們開發人員需要考慮的問題

本篇文章,將介紹 MongoDB 的 客戶端欄位級加密 功能,英文全稱為 Client-Side Field Level Encryption,在有些地方會看到簡稱為 CSFLE,代表的是一個意思,下文有些地方也會這樣稱呼。

該功能允許開發人員將資料儲存到 MongoDB 伺服器之前選擇性的指定資料欄位進行加密,這些加密/解密操作都是事先在客戶端完成,與伺服器通訊時完全是加密的,最終只有配置了 CSFLE 客戶端才能讀取和寫入敏感資料欄位。

文末列舉了幾個使用中的常見錯誤原因,如有遇到類似錯誤可以做為參考。

環境要求

MongoDB Server 選擇:MongoDB 客戶端欄位級加密分為自動加密、手動加密兩種型別,自動加密社群版是不支援的,需要 MongoDB Server 4.2 企業版 或 MongoDB Atlas,學習使用推薦 MongoDB Atlas,它是在雲伺服器中託管的 MongoDB 伺服器,不需要安裝,且提供了免費的入門套餐是夠我們學習使用了。

驅動相容性:使用支援 CSFLE 功能的 Node.js MongoDB 驅動程式,3.4+ 以上版本是支援的,快速入門。

libmongocrypt:客戶端欄位級加密依賴 libmongocrypt,它是 MongoDB 驅動程式實現客戶端加密/解密的核心元件,對應的 Node.js NPM 包為 mongodb-client-encryption,需要注意這個包依賴於 libbson 和 libmongocrypt C 庫,需要 C++ 工具鏈,但是做為 Node.js Addons 外掛,其已經利用 prebuild 在 CI 期間做了模組的預先編譯,直接 npm i mongodb-client-encryption 安裝即可,如果網路環境問題連結不上 github.com 可能就很麻煩了需要手動構建、編譯,因為對模組的預先編譯是放在 Github 上的。

mongocryptd:客戶端加密必須要 mongocryptd 程序啟動才能正常工作,剛開始一直遇到一個問題: MongoError: BSON field 'insert.jsonSchema' is an unknown field. This command may be meant for a mongocryptd process. 貌似就是因為 mongocryptd 程序沒有啟動導致的。在 MongoDB Server 企業版中包含 mongocryptd 這個元件的,解決辦法也很簡單就是本機安裝下企業版,儘管我們這裡使用的是 MongoDB Atlas 也要安裝的,安裝方法參考 docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-os-x。

專案準備

做一些初始化工作,安裝依賴、配置檔案、建立一個常規的 MongoDB client。

專案初始化

mkdir nodejs-mongodb-client-encryption
cd nodejs-mongodb-client-encryption
npm init
npm i mongodb mongodb-client-encryption -S

配置檔案

建立一個 index.js 檔案,核心程式碼邏輯都在該檔案編寫,

// index.js
const base64 = require('uuid-base64');
const { MongoClient, Binary } = require('mongodb');
const { ClientEncryption } = require('mongodb-client-encryption');
const fs = require('fs');

// 配置
const config = {
connectionString: '${替換為自己的 MongoDB 連結字串}',
keyVaultDb: 'encryption', // encryption 表示金鑰保管資料庫
keyVaultCollection: '__keyVault', // __keyVault 表示集合
keyVaultNamespace: `encryption.__keyVault`, // 金鑰庫名稱空間
keyAltNames: 'test-data-key',
masterKeyPath: 'master-key.txt'
}
const LOCAL_MASTER_KEY = fs.readFileSync(config.masterKeyPath); // 讀取本地主金鑰
const kmsProviders = { // 指定 KMS 提供程式設定
local: {
key: LOCAL_MASTER_KEY,
},
};

建立常規 Client

/**
* 獲取常規 Mongo 客戶端
* @param {String} connectionString
* @returns
*/
function getRegularClient(connectionString) {
const client = new MongoClient(connectionString, {
useNewUrlParser: true,
useUnifiedTopology: true,
});

return client.connect();
}

資料加密金鑰

MongoDB 驅動程式自動加密/解密時需要訪問事先建立的資料加密金鑰,而這個金鑰經過程式的處理會儲存在金鑰保管資料庫的集合中,以下是建立一個數據加密金鑰的互動圖。

建立主金鑰

建立 MongoDB 資料加密金鑰還需要另外一個稱為 “主金鑰” 的金鑰進行加密,下圖展示了建立主金鑰的流程: 主金鑰的儲存,生產環境 MongoDB 官方的推薦是使用 金鑰管理服務(KMS) :亞馬遜網路服務 KMS、Azure 金鑰保管庫、谷歌雲平臺金鑰管理,更多內容可閱讀 客戶端欄位級加密:使用 KMS 儲存主金鑰。

學習為目的,簡單方便些可使用 本地金鑰提供程式儲存主金鑰 ,這種方式不安全,不適合生產。

建立一個指令碼檔案 create-master-key.js ,生成一個 96 位元組的金鑰檔案,並寫入到本地檔案系統的 master-key.txt 檔案中。

// create-master-key.js
const fs = require('fs');
const crypto = require('crypto');

try {
fs.writeFileSync('master-key.txt', crypto.randomBytes(96));
} catch (err) {
console.error(err);
}

指定 KMS 程式配置

客戶端使用如下配置發現主金鑰,local 表示的是使用本地主金鑰。

const LOCAL_MASTER_KEY = fs.readFileSync(config.masterKeyPath); // 讀取本地主金鑰
const kmsProviders = { // 指定 KMS 提供程式設定
local: {
key: LOCAL_MASTER_KEY,
},
};

獲取或建立資料加密金鑰

寫一個函式 getOrCreateDataKey 分別傳入建立的常規 client、上面指定的 KMS 程式配置,該方法目的是獲取一個數據金鑰,如果不存在則建立,實現為以下幾個步驟:

  • 在金鑰保管庫集合的 keyAltNames 欄位上先設定唯一索引,這裡建立的是一個部分索引,符合條件的才會建立。

  • 檢查是否已建立資料加密金鑰,若建立則立即返回。

  • 若未建立資料加密金鑰,向指定的金鑰保管庫集合建立一條新的資料金鑰。

/**
* 獲取或建立資料加密金鑰
* 如果已存在 dataKey 則返回,否則建立一條 dataKey
*/

async function getOrCreateDataKey(regularClient, kmsProviders) {
// 在金鑰保管庫集合的 keyAltNames 欄位上先設定索引
await regularClient
.db(config.keyVaultDb)
.collection(config.keyVaultCollection)
.createIndex("keyAltNames", {
unique: true,
partialFilterExpression: {
keyAltNames: {
$exists: true
}
}
});

// 檢查是否已建立資料加密金鑰
const dataKeyInfo = await regularClient
.db(config.keyVaultDb)
.collection(config.keyVaultCollection)
.findOne({
keyAltNames: {
$in: [config.keyAltNames]
}
});
if (dataKeyInfo) { // 存在立即返回
return dataKeyInfo['_id'].toString("base64");
}

// 建立一條新的資料金鑰
const encryption = new ClientEncryption(regularClient, {
keyVaultNamespace: config.keyVaultNamespace,
kmsProviders,
});
const dataKey = await encryption.createDataKey('local', {
keyAltNames: [config.keyAltNames]
});
return dataKey.toString('base64');
}

驗證資料加密金鑰是否成功建立

呼叫編寫好的方法,驗證下資料加密金鑰是否建立成功。

(async () => {
let regularClient;
try {
// 建立常規 MongoDB 客戶端
regularClient = await getRegularClient(config.connectionString);
// 獲取資料加密金鑰
const base64DataKeyId = await getOrCreateDataKey(regularClient, kmsProviders);
} catch (err) {
console.error(err);
regularClient.close();
}
})();

我使用 Robo 3T 連結的 Atlas 叢集,如果一切正常,你會看到在 encryption.__keyVault 集合中有如下一條金鑰記錄,_id 欄位就是為我們需要的資料加密金鑰,使用 Base64 格式編碼。

JSON Schema 定義

Node.js 驅動程式使用 JSON Schema 定義集合需要加密的欄位,文件型別定義使用 BSON 型別。

  • encryptMetadata.keyId:在根級別配置資料加密金鑰,properties 中的每個欄位預設都繼承該金鑰,除非特別指定,參考 docs.mongodb.com/manual/reference/security-client-side-automatic-json-schema/#encryptmetadata-schema-keyword。

  • algorithm:指定加密演算法,AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic 為 確定性加密演算法 ,對讀取操作提供了更好的支援,安全係數相對沒有**隨機加密 **AEAD_AES_256_CBC_HMAC_SHA_512-Random 高,隨機加密演算法每次執行加密都會輸出不同的值。

/**
* 使用 JSON Schema 定義集合需要加密的欄位
* @param {String} base64DataKeyId
* @returns
*/

function getSchemaMap(base64DataKeyId) {
// 使用 JSON Schema 指定加密欄位
const userJsonSchema = {
bsonType: 'object',
encryptMetadata: {
keyId: [new Binary(Buffer.from(base64DataKeyId, 'base64'), 4)]
},
properties: {
phone: {
encrypt: {
bsonType: 'string',
algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
}
},
password: {
encrypt: {
bsonType: 'string',
algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random'
}
},
emergencyContact: {
bsonType: 'object',
properties: {
phone: {
encrypt: {
bsonType: 'string',
algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'
}
},
}
}
}
}

// 將 JSON 模式對映到集合上
const schemaMap = {
'test.users': userJsonSchema
};

return schemaMap;
}

CSFLE 客戶端驗證讀寫操作

在有了資料加密金鑰、JSON Schema 之後可以建立一個支援 CSFLE 的 Mongo client,該客戶端和 MongDB 伺服器互動,讀取/寫入帶有加密欄位的資料。

讀寫操作流程圖

下圖展示了客戶端應用程式和驅動程式為寫入欄位級加密資料的一個步驟: 下圖展示了客戶端應用程式和驅動程式為讀取加密後欄位進行解密操作的一個過程:

建立 CSFLE 客戶端

建立 CSFLE 的 mongo client 與常規 mongo client 相比較,需要多傳入 autoEncryption 物件,以下引數含義分別為:

  • keyVaultNamespace:存放資料加密金鑰的金鑰保管庫集合名稱。

  • kmsProviders:指定本地主金鑰。

  • schemaMap:需要加密欄位的一些定義。

function getCSFLEClient(schemaMap, kmsProviders) {
const secureClient = new MongoClient(config.connectionString, {
useNewUrlParser: true,
useUnifiedTopology: true,
monitorCommands: true,
autoEncryption: {
bypassAutoEncryption: true,
keyVaultNamespace: config.keyVaultNamespace,
kmsProviders,
schemaMap
}
});

return secureClient.connect();
}

(async () => {
try {
const regularClient = await getRegularClient(config.connectionString);
const base64DataKeyId = await getOrCreateDataKey(regularClient, kmsProviders);
const schemaMap = getSchemaMap(base64DataKeyId);
const csfleClient = await getCSFLEClient(schemaMap, kmsProviders);

// 執行讀寫操作
} catch (err) {
console.error(err);
}
})();

執行讀寫操作驗證

在擁有 CSFLE 客戶端後,執行一些讀寫操作,建立一條使用者記錄,下面的程式碼和我們常規的讀寫操作沒什麼區別,並且 phone 這個欄位雖然是經過加密的,我們仍可使用該欄位做為索引,更新/查詢資料。

(async () => {
try {
// ...
const db = csfleClient.db('test');
const userColl = db.collection('users');
const doc = {
name: '小張',
phone: '18800030009',
password: '123456',
emergencyContact: {
name: '小李',
phone: '16600260023'
}
};
const query = { phone: doc.phone };
await userColl.updateOne(query, { $set: doc }, { upsert: true });
const result = await userColl.findOne(query);
console.log(result);
} catch (err) {
console.error(err);
}
})();

當成功插入一條記錄之後,在 Robo 3T 工具查詢該集合,可以看到需要的欄位都已經做了加密,儘管我是一個管理員能夠檢視資料,也無法檢視這些隱私資料。

image.png

只能通過程式正確的建立了 CSFLE 的客戶端才能讀取出解密後的資料。

image.png

幾個常見錯誤

文中示例測試時常見的幾個錯誤,可以做為參考。

認證失敗

遇到 Authentication failed 錯誤,基本上都是連線字串的賬號密碼或許可權錯誤,使用 MongoDB Atlas 的需要檢查下資料庫的訪問許可權配置

image.png
MongoServerError: bad auth : Authentication failed.
...
ok: 0,
code: 8000,
codeName: 'AtlasError',
[Symbol(errorLabels)]: Set(0) {}
}

建立加密客戶端連結失敗

下面的報錯很簡單就是伺服器連結不上。需要注意的是文中建立加密客戶端還會去連結本地安裝的 MongoDB 企業版 Server,在本地啟動 MongoDB 企業版 Server 時需要指定下埠 bin/mongod --dbpath data --logpath logs/mongo.log --port 27020

MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:27020
at Timeout._onTimeout (/Users/***********/nodejs-mongodb-client-encryption/node_modules/mongodb/lib/sdam/topology.js:318:38)
at listOnTimeout (internal/timers.js:554:17)
at processTimers (internal/timers.js:497:7) {
reason: TopologyDescription {
type: 'Unknown',
servers: Map(1) { 'localhost:27020' => [ServerDescription] },
stale: false,
compatible: true,
heartbeatFrequencyMS: 10000,
localThresholdMS: 15,
logicalSessionTimeoutMinutes: undefined
},
code: undefined,
[Symbol(errorLabels)]: Set(0) {}
}

mongocryptd 程序注意事項

在剛開始的環境要求裡有提到過 mongocryptd 程序,它會在這裡檢查 JSON Schema 中定義的加密指令,也就是 getCSFLEClient() 傳入的 schemaMap 引數,如果 mongocryptd 程序沒有啟動,這裡會一直報錯。

以下是我最開始一直遇到的一個問題,解決辦法很簡單:

  • 第一步,本機安裝下企業版

  • 第二步,建立加密的 MongoDB 客戶端時,連結引數要設定 autoEncryption.bypassAutoEncryption=true 會自動生成 mongocryptd 程序。
writeError occurred: MongoError: BSON field 'insert.jsonSchema' is an unknown field. This command may be meant for a mongocryptd process.
at MessageStream.messageHandler (/Users/quzhenfei/Documents/study/node_modules/mongodb/lib/cmap/connection.js:268:20)
at MessageStream.emit (events.js:314:20)
at processIncomingData (/Users/quzhenfei/Documents/study/node_modules/mongodb/lib/cmap/message_stream.js:144:12)
at MessageStream._write (/Users/quzhenfei/Documents/study/node_modules/mongodb/lib/cmap/message_stream.js:42:5)
at writeOrBuffer (_stream_writable.js:352:12)
at MessageStream.Writable.write (_stream_writable.js:303:10)
at TLSSocket.ondata (_stream_readable.js:713:22)
at TLSSocket.emit (events.js:314:20)
at addChunk (_stream_readable.js:303:12)
at readableAddChunk (_stream_readable.js:279:9) {
operationTime: Timestamp { _bsontype: 'Timestamp', low_: 1, high_: 1632613160 },
ok: 0,
code: 4662500,
codeName: 'Location4662500',
'$clusterTime': {
clusterTime: Timestamp { _bsontype: 'Timestamp', low_: 1, high_: 1632613160 },
signature: { hash: [Binary], keyId: [Long] }
}
}

總結

MongoDB 提供的客戶端欄位級自動加密,對於有資料隱私需要加密保護的還是很方便的,在配置了 CSFLE 客戶端後應用程式在讀寫操作時和常規的客戶端讀寫操作是沒有差別的,唯一的阻礙可能是僅企業版支援。

文中我們將主金鑰儲存放在了本地的檔案系統中,這在本地測試環境是可以的,但是生產環境不要用這種方式,因為任何能夠訪問您本地檔案系統主金鑰的人都可以讀取您的資料加密金鑰,建議放在更安全的地方,例如金鑰管理系統(KMS)。

Reference

  • docs.mongodb.com/drivers/security/client-side-field-level-encryption-guide/#e.-perform-encrypted-read-write-operations

  • 基於 Mongo Shell 的手動加密 https://www.modb.pro/db/100877

  • www.mongodb.com/community/forums/t/fle-mongoerror-bson-field-insert-jsonschema-is-an-unknown-field/5472/7

  • www.mongodb.com/developer/how-to/client-side-field-level-encryption-csfle-mongodb-node

  • mongodb.github.io/node-mongodb-native/3.4/reference/client-side-encryption/

篇幅有限,閱讀原文,檢視文中示例程式碼。

3 6 0 W 3 C E C M A T C 3 9 L e a d e r