Node.js 結(jié)合 MongoDB 實(shí)現(xiàn)字段級(jí)自動(dòng)加密
關(guān)注并將「趣談前端」設(shè)為星標(biāo)
定期推送技術(shù)干貨/優(yōu)秀開(kāi)源/技術(shù)思維
某些場(chǎng)景下,對(duì)于數(shù)據(jù)隱私會(huì)有較高的要求,例如,用戶系統(tǒng)的個(gè)人信息(身份證、手機(jī)號(hào))、醫(yī)患系統(tǒng)的患者信息等,怎么用技術(shù)手段安全的保護(hù)這些敏感數(shù)據(jù)是我們開(kāi)發(fā)人員需要考慮的問(wèn)題。
本篇文章,將介紹 MongoDB 的客戶端字段級(jí)加密功能,英文全稱為 Client-Side Field Level Encryption,在有些地方會(huì)看到簡(jiǎn)稱為 CSFLE,代表的是一個(gè)意思,下文有些地方也會(huì)這樣稱呼。
該功能允許開(kāi)發(fā)人員將數(shù)據(jù)保存到 MongoDB 服務(wù)器之前選擇性的指定數(shù)據(jù)字段進(jìn)行加密,這些加密/解密操作都是事先在客戶端完成,與服務(wù)器通信時(shí)完全是加密的,最終只有配置了 CSFLE 客戶端才能讀取和寫(xiě)入敏感數(shù)據(jù)字段。
文末列舉了幾個(gè)使用中的常見(jiàn)錯(cuò)誤原因,如有遇到類似錯(cuò)誤可以做為參考。
環(huán)境要求
MongoDB Server 選擇:MongoDB 客戶端字段級(jí)加密分為自動(dòng)加密、手動(dòng)加密兩種類型,自動(dòng)加密社區(qū)版是不支持的,需要 MongoDB Server 4.2 企業(yè)版 或 MongoDB Atlas,學(xué)習(xí)使用推薦 MongoDB Atlas,它是在云服務(wù)器中托管的 MongoDB 服務(wù)器,不需要安裝,且提供了免費(fèi)的入門套餐是夠我們學(xué)習(xí)使用了。
驅(qū)動(dòng)兼容性:使用支持 CSFLE 功能的 Node.js MongoDB 驅(qū)動(dòng)程序,3.4+ 以上版本是支持的,快速入門。
libmongocrypt:客戶端字段級(jí)加密依賴 libmongocrypt,它是 MongoDB 驅(qū)動(dòng)程序?qū)崿F(xiàn)客戶端加密/解密的核心組件,對(duì)應(yīng)的 Node.js NPM 包為 mongodb-client-encryption,需要注意這個(gè)包依賴于 libbson 和 libmongocrypt C 庫(kù),需要 C++ 工具鏈,但是做為 Node.js Addons 插件,其已經(jīng)利用 prebuild 在 CI 期間做了模塊的預(yù)先編譯,直接 npm i mongodb-client-encryption 安裝即可,如果網(wǎng)絡(luò)環(huán)境問(wèn)題鏈接不上 github.com 可能就很麻煩了需要手動(dòng)構(gòu)建、編譯,因?yàn)閷?duì)模塊的預(yù)先編譯是放在 Github 上的。
mongocryptd:客戶端加密必須要 mongocryptd 進(jìn)程啟動(dòng)才能正常工作,剛開(kāi)始一直遇到一個(gè)問(wèn)題:MongoError: BSON field 'insert.jsonSchema' is an unknown field. This command may be meant for a mongocryptd process. 貌似就是因?yàn)?mongocryptd 進(jìn)程沒(méi)有啟動(dòng)導(dǎo)致的。在 MongoDB Server 企業(yè)版中包含 mongocryptd 這個(gè)組件的,解決辦法也很簡(jiǎn)單就是本機(jī)安裝下企業(yè)版,盡管我們這里使用的是 MongoDB Atlas 也要安裝的,安裝方法參考 docs.mongodb.com/manual/tutorial/install-mongodb-enterprise-on-os-x。
項(xiàng)目準(zhǔn)備
做一些初始化工作,安裝依賴、配置文件、創(chuàng)建一個(gè)常規(guī)的 MongoDB client。
項(xiàng)目初始化
mkdir?nodejs-mongodb-client-encryption
cd?nodejs-mongodb-client-encryption
npm?init
npm?i?mongodb?mongodb-client-encryption?-S
配置文件
創(chuàng)建一個(gè) index.js 文件,核心代碼邏輯都在該文件編寫(xiě),
//?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?表示密鑰保管數(shù)據(jù)庫(kù)
??keyVaultCollection:?'__keyVault',?//?__keyVault?表示集合
??keyVaultNamespace:?`encryption.__keyVault`,?//?密鑰庫(kù)命名空間
??keyAltNames:?'test-data-key',
??masterKeyPath:?'master-key.txt'
}
const?LOCAL_MASTER_KEY?=?fs.readFileSync(config.masterKeyPath);?//?讀取本地主密鑰
const?kmsProviders?=?{?//?指定?KMS?提供程序設(shè)置
??local:?{
????key:?LOCAL_MASTER_KEY,
??},
};
創(chuàng)建常規(guī) Client
/**
?*?獲取常規(guī)?Mongo?客戶端
?*?@param?{String}?connectionString
?*?@returns?
?*/
function?getRegularClient(connectionString)?{
??const?client?=?new?MongoClient(connectionString,?{
????useNewUrlParser:?true,
????useUnifiedTopology:?true,
??});
??return?client.connect();
}
數(shù)據(jù)加密密鑰
MongoDB 驅(qū)動(dòng)程序自動(dòng)加密/解密時(shí)需要訪問(wèn)事先創(chuàng)建的數(shù)據(jù)加密密鑰,而這個(gè)密鑰經(jīng)過(guò)程序的處理會(huì)存儲(chǔ)在密鑰保管數(shù)據(jù)庫(kù)的集合中,以下是創(chuàng)建一個(gè)數(shù)據(jù)加密密鑰的交互圖。
創(chuàng)建主密鑰
創(chuàng)建 MongoDB 數(shù)據(jù)加密密鑰還需要另外一個(gè)稱為 “主密鑰” 的密鑰進(jìn)行加密,下圖展示了創(chuàng)建主密鑰的流程:
主密鑰的存儲(chǔ),生產(chǎn)環(huán)境 MongoDB 官方的推薦是使用密鑰管理服務(wù)(KMS):亞馬遜網(wǎng)絡(luò)服務(wù) KMS、Azure 密鑰保管庫(kù)、谷歌云平臺(tái)密鑰管理,更多內(nèi)容可閱讀 客戶端字段級(jí)加密:使用 KMS 存儲(chǔ)主密鑰。
學(xué)習(xí)為目的,簡(jiǎn)單方便些可使用本地密鑰提供程序存儲(chǔ)主密鑰,這種方式不安全,不適合生產(chǎn)。
創(chuàng)建一個(gè)腳本文件 create-master-key.js,生成一個(gè) 96 字節(jié)的密鑰文件,并寫(xiě)入到本地文件系統(tǒng)的 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 程序配置
客戶端使用如下配置發(fā)現(xiàn)主密鑰,local 表示的是使用本地主密鑰。
const?LOCAL_MASTER_KEY?=?fs.readFileSync(config.masterKeyPath);?//?讀取本地主密鑰
const?kmsProviders?=?{?//?指定?KMS?提供程序設(shè)置
??local:?{
????key:?LOCAL_MASTER_KEY,
??},
};
獲取或創(chuàng)建數(shù)據(jù)加密密鑰
寫(xiě)一個(gè)函數(shù) getOrCreateDataKey 分別傳入創(chuàng)建的常規(guī) client、上面指定的 KMS 程序配置,該方法目的是獲取一個(gè)數(shù)據(jù)密鑰,如果不存在則創(chuàng)建,實(shí)現(xiàn)為以下幾個(gè)步驟:
在密鑰保管庫(kù)集合的 keyAltNames 字段上先設(shè)置唯一索引,這里創(chuàng)建的是一個(gè)部分索引,符合條件的才會(huì)創(chuàng)建。 檢查是否已創(chuàng)建數(shù)據(jù)加密密鑰,若創(chuàng)建則立即返回。 若未創(chuàng)建數(shù)據(jù)加密密鑰,向指定的密鑰保管庫(kù)集合創(chuàng)建一條新的數(shù)據(jù)密鑰。
/**
?*?獲取或創(chuàng)建數(shù)據(jù)加密密鑰
?*?如果已存在?dataKey?則返回,否則創(chuàng)建一條?dataKey
?*/
async?function?getOrCreateDataKey(regularClient,?kmsProviders)?{
??//?在密鑰保管庫(kù)集合的?keyAltNames?字段上先設(shè)置索引
??await?regularClient
????.db(config.keyVaultDb)
????.collection(config.keyVaultCollection)
????.createIndex("keyAltNames",?{
??????unique:?true,
??????partialFilterExpression:?{
????????keyAltNames:?{
??????????$exists:?true
????????}
??????}
????});
??//?檢查是否已創(chuàng)建數(shù)據(jù)加密密鑰
??const?dataKeyInfo?=?await?regularClient
????.db(config.keyVaultDb)
????.collection(config.keyVaultCollection)
????.findOne({
??????keyAltNames:?{
????????$in:?[config.keyAltNames]
??????}
????});
??if?(dataKeyInfo)?{?//?存在立即返回
????return?dataKeyInfo['_id'].toString("base64");
??}
??//?創(chuàng)建一條新的數(shù)據(jù)密鑰
??const?encryption?=?new?ClientEncryption(regularClient,?{
????keyVaultNamespace:?config.keyVaultNamespace,
????kmsProviders,
??});
??const?dataKey?=?await?encryption.createDataKey('local',?{
????keyAltNames:?[config.keyAltNames]
??});
??return?dataKey.toString('base64');
}
驗(yàn)證數(shù)據(jù)加密密鑰是否成功創(chuàng)建
調(diào)用編寫(xiě)好的方法,驗(yàn)證下數(shù)據(jù)加密密鑰是否創(chuàng)建成功。
(async?()?=>?{
??let?regularClient;
??try?{
????//?創(chuàng)建常規(guī)?MongoDB?客戶端
????regularClient?=?await?getRegularClient(config.connectionString);
????//?獲取數(shù)據(jù)加密密鑰
????const?base64DataKeyId?=?await?getOrCreateDataKey(regularClient,?kmsProviders);
??}?catch?(err)?{
????console.error(err);
????regularClient.close();
??}
})();
我使用 Robo 3T 鏈接的 Atlas 集群,如果一切正常,你會(huì)看到在 encryption.__keyVault 集合中有如下一條密鑰記錄,_id 字段就是為我們需要的數(shù)據(jù)加密密鑰,使用 Base64 格式編碼。
JSON Schema 定義
Node.js 驅(qū)動(dòng)程序使用 JSON Schema 定義集合需要加密的字段,文檔類型定義使用 BSON 類型。
encryptMetadata.keyId:在根級(jí)別配置數(shù)據(jù)加密密鑰,properties 中的每個(gè)字段默認(rèn)都繼承該密鑰,除非特別指定,參考 docs.mongodb.com/manual/reference/security-client-side-automatic-json-schema/#encryptmetadata-schema-keyword。 algorithm:指定加密算法,AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic 為確定性加密算法,對(duì)讀取操作提供了更好的支持,安全系數(shù)相對(duì)沒(méi)有**隨機(jī)加密 **AEAD_AES_256_CBC_HMAC_SHA_512-Random 高,隨機(jī)加密算法每次執(zhí)行加密都會(huì)輸出不同的值。
/**
?*?使用?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 客戶端驗(yàn)證讀寫(xiě)操作
在有了數(shù)據(jù)加密密鑰、JSON Schema 之后可以創(chuàng)建一個(gè)支持 CSFLE 的 Mongo client,該客戶端和 MongDB 服務(wù)器交互,讀取/寫(xiě)入帶有加密字段的數(shù)據(jù)。
讀寫(xiě)操作流程圖
下圖展示了客戶端應(yīng)用程序和驅(qū)動(dòng)程序?yàn)閷?xiě)入字段級(jí)加密數(shù)據(jù)的一個(gè)步驟:
下圖展示了客戶端應(yīng)用程序和驅(qū)動(dòng)程序?yàn)樽x取加密后字段進(jìn)行解密操作的一個(gè)過(guò)程:
創(chuàng)建 CSFLE 客戶端
創(chuàng)建 CSFLE 的 mongo client 與常規(guī) mongo client 相比較,需要多傳入 autoEncryption 對(duì)象,以下參數(shù)含義分別為:
keyVaultNamespace:存放數(shù)據(jù)加密密鑰的密鑰保管庫(kù)集合名稱。 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);
????
??//?執(zhí)行讀寫(xiě)操作
??}?catch?(err)?{
????console.error(err);
??}
})();
執(zhí)行讀寫(xiě)操作驗(yàn)證
在擁有 CSFLE 客戶端后,執(zhí)行一些讀寫(xiě)操作,創(chuàng)建一條用戶記錄,下面的代碼和我們常規(guī)的讀寫(xiě)操作沒(méi)什么區(qū)別,并且 phone 這個(gè)字段雖然是經(jīng)過(guò)加密的,我們?nèi)钥墒褂迷撟侄巫鰹樗饕?查找數(shù)據(jù)。
(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);
??}
})();
當(dāng)成功插入一條記錄之后,在 Robo 3T 工具查詢?cè)摷希梢钥吹叫枰淖侄味家呀?jīng)做了加密,盡管我是一個(gè)管理員能夠查看數(shù)據(jù),也無(wú)法查看這些隱私數(shù)據(jù)。

只能通過(guò)程序正確的創(chuàng)建了 CSFLE 的客戶端才能讀取出解密后的數(shù)據(jù)。

幾個(gè)常見(jiàn)錯(cuò)誤
文中示例測(cè)試時(shí)常見(jiàn)的幾個(gè)錯(cuò)誤,可以做為參考。
認(rèn)證失敗
遇到 Authentication failed 錯(cuò)誤,基本上都是連接字符串的賬號(hào)密碼或權(quán)限錯(cuò)誤,使用 MongoDB Atlas 的需要檢查下數(shù)據(jù)庫(kù)的訪問(wèn)權(quán)限配置

MongoServerError:?bad?auth?:?Authentication?failed.
??...
??ok:?0,
??code:?8000,
??codeName:?'AtlasError',
??[Symbol(errorLabels)]:?Set(0)?{}
}
創(chuàng)建加密客戶端鏈接失敗
下面的報(bào)錯(cuò)很簡(jiǎn)單就是服務(wù)器鏈接不上。需要注意的是文中創(chuàng)建加密客戶端還會(huì)去鏈接本地安裝的 MongoDB 企業(yè)版 Server,在本地啟動(dòng) MongoDB 企業(yè)版 Server 時(shí)需要指定下端口 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 進(jìn)程注意事項(xiàng)
在剛開(kāi)始的環(huán)境要求里有提到過(guò) mongocryptd 進(jìn)程,它會(huì)在這里檢查 JSON Schema 中定義的加密指令,也就是 getCSFLEClient() 傳入的 schemaMap 參數(shù),如果 mongocryptd 進(jìn)程沒(méi)有啟動(dòng),這里會(huì)一直報(bào)錯(cuò)。
以下是我最開(kāi)始一直遇到的一個(gè)問(wèn)題,解決辦法很簡(jiǎn)單:
第一步,本機(jī)安裝下企業(yè)版 第二步,創(chuàng)建加密的 MongoDB 客戶端時(shí),鏈接參數(shù)要設(shè)置 autoEncryption.bypassAutoEncryption=true會(huì)自動(dòng)生成 mongocryptd 進(jìn)程。
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]?}
??}
}
總結(jié)
MongoDB 提供的客戶端字段級(jí)自動(dòng)加密,對(duì)于有數(shù)據(jù)隱私需要加密保護(hù)的還是很方便的,在配置了 CSFLE 客戶端后應(yīng)用程序在讀寫(xiě)操作時(shí)和常規(guī)的客戶端讀寫(xiě)操作是沒(méi)有差別的,唯一的阻礙可能是僅企業(yè)版支持。
文中我們將主密鑰存儲(chǔ)放在了本地的文件系統(tǒng)中,這在本地測(cè)試環(huán)境是可以的,但是生產(chǎn)環(huán)境不要用這種方式,因?yàn)槿魏文軌蛟L問(wèn)您本地文件系統(tǒng)主密鑰的人都可以讀取您的數(shù)據(jù)加密密鑰,建議放在更安全的地方,例如密鑰管理系統(tǒng)(KMS)。
Reference
docs.mongodb.com/drivers/security/client-side-field-level-encryption-guide/#e.-perform-encrypted-read-write-operations 基于 Mongo Shell 的手動(dòng)加密 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/
?? 看完三件事
如果你覺(jué)得這篇內(nèi)容對(duì)你挺有啟發(fā),我想邀請(qǐng)你幫我三個(gè)小忙:
點(diǎn)個(gè)【在看】,或者分享轉(zhuǎn)發(fā),讓更多的人也能看到這篇內(nèi)容 關(guān)注公眾號(hào)【趣談前端】,定期分享?工程化?/?可視化?/?低代碼?/?優(yōu)秀開(kāi)源。

從零搭建全棧可視化大屏制作平臺(tái)V6.Dooring
點(diǎn)個(gè)在看你最好看

