HomeLearnHow-toHow to use MongoDB Client-Side Field Level Encryption (CSFLE) with Node.js

How to use MongoDB Client-Side Field Level Encryption (CSFLE) with Node.js

Published: Jan 06, 2021

  • JavaScript
  • Field Level Encryption
  • Security
  • ...

By Joe Karlsson

Rate this article

Have you ever had to develop an application that stored sensitive data, like credit card numbers or social security numbers? This is a super common use case for databases, and it can be a pain to save this data is secure way. Luckily for us there are some incredible security features that come packaged with MongoDB. For example, you should know that with MongoDB, you can take advantage of:

The following diagram is a list of MongoDB security features offered and the potential security vulnerabilities that they address:

Diagram that describes MongoDB security features and the potential vulnerabilities that they address

Client-side Field Level Encryption allows the engineers to specify the fields of a document that should be kept encrypted. Sensitive data is transparently encrypted/decrypted by the client and only communicated to and from the server in encrypted form. This mechanism keeps the specified data fields secure in encrypted form on both the server and the network. While all clients have access to the non-sensitive data fields, only appropriately-configured CSFLE clients are able to read and write the sensitive data fields.

In this post, we will design a Node.js client that could be used to safely store select fields as part of a medical application.

#The Requirements

There are a few requirements that must be met prior to attempting to use Client-Side Field Level Encryption (CSFLE) with the Node.js driver.

This tutorial will focus on automatic encryption. While this tutorial will use MongoDB Atlas, you're going to need to be using version 4.2 or newer for MongoDB Atlas or MongoDB Enterprise Edition. You will not be able to use automatic field level encryption with MongoDB Community Edition.

The assumption is that you're familiar with developing Node.js applications that use MongoDB. If you want a refresher, take a look at the quick start series that we published on the topic.

#Installing the Libmongocrypt and Mongocryptd Binaries and Libraries

Because of the libmongocrypt and mongocryptd requirements, it's worth reviewing how to install and configure them. We'll be exploring installation on macOS, but refer to the documentation for libmongocrypt and mongocryptd for your particular operating system.

#libmongocrypt

libmongocrypt is required for automatic field level encryption, as it is the component that is responsible for performing the encryption or decryption of the data on the client with the MongoDB 4.2-compatible Node drivers. Now, there are currently a few solutions for installing the libmongocrypt library on macOS. However, the easiest is with Homebrew. If you've got Homebrew installed, you can install libmongocrypt with the following command:

1brew install mongodb/brew/libmongocrypt

I ran into an issue with libmongocrypt when I tried to run my code, because libmongocrypt was trying to statically link against libmongocrypt instead of dynamically linking. I have submitted an issue to the team to fix this issue, but to fix it, I had to run:

Screenshot of the terminal showing the error I got when using a static link against libmongocrypt.
1export BUILD_TYPE=dynamic

#mongocryptd

mongocryptd is required for automatic field level encryption and is included as a component in the MongoDB Enterprise Server package. mongocryptd is only responsible for supporting automatic client-side field level encryption and does not perform encryption or decryption.

You'll want to consult the documentation on how to obtain the mongocryptd binary as each operating system has different steps.

For macOS, you'll want to download MongoDB Enterprise Edition from the MongoDB Download Center. You can refer to the Enterprise Edition installation instructions for macOS to install, but the gist of the installation involves extracting the TAR file and moving the files to the appropriate directory.

By this point, all the appropriate components for client-side field level encryption should be installed or available. Make sure that you are running MongoDB enterprise on your client while using CSFLE, even if you are saving your data to Atlas.

#Project Setup

Let's start by setting up all the files and dependencies we will need. In a new directory, create the following files, running the following command:

1touch clients.js helpers.js make-data-key.js

Be sure to initialize a new NPM project, since we will be using several NPM dependencies.

1npm init --yes

And let's just go ahead and install all the packages that we will be using now.

1npm install -S mongodb mongodb-client-encryption node-gyp

Note: The complete codebase for this project can be found here: https://github.com/JoeKarlsson/client-side-field-level-encryption-csfle-mongodb-node-demo

#Create a Data Key in MongoDB for Encrypting and Decrypting Document Fields

MongoDB Client-Side Field Level Encryption (CSFLE) uses an encryption strategy called envelope encryption in which keys used to encrypt/decrypt data (called data encryption keys) are encrypted with another key (called the master key). The following diagram shows how the master key is created and stored:

Diagram that describes creating the master key when using a local provider
Diagram that describes creating the master key when using a local provider

Warning

The Local Key Provider is not suitable for production.

The Local Key Provider is an insecure method of storage and is therefore not recommended if you plan to use CSFLE in production. Instead, you should configure a master key in a Key Management System (KMS) which stores and decrypts your data encryption keys remotely.

To learn how to use a KMS in your CSFLE implementation, read the Client-Side Field Level Encryption: Use a KMS to Store the Master Key guide.

1// clients.js
2
3const fs = require("fs")
4const mongodb = require("mongodb")
5const { ClientEncryption } = require("mongodb-client-encryption")
6const { MongoClient, Binary } = mongodb
7
8module.exports = {
9readMasterKey: function (path = "./master-key.txt") {
10 return fs.readFileSync(path)
11},
12CsfleHelper: class {
13 constructor({ kmsProviders = null, keyAltNames = "demo-data-key", keyDB = "encryption", keyColl = "__keyVault", schema = null, connectionString = "mongodb://localhost:27017", mongocryptdBypassSpawn = false, mongocryptdSpawnPath = "mongocryptd" } = {}) {
14 if (kmsProviders === null) {
15 throw new Error("kmsProviders is required")
16 }
17 this.kmsProviders = kmsProviders
18 this.keyAltNames = keyAltNames
19 this.keyDB = keyDB
20 this.keyColl = keyColl
21 this.keyVaultNamespace = `${keyDB}.${keyColl}`
22 this.schema = schema
23 this.connectionString = connectionString
24 this.mongocryptdBypassSpawn = mongocryptdBypassSpawn
25 this.mongocryptdSpawnPath = mongocryptdSpawnPath
26 this.regularClient = null
27 this.csfleClient = null
28 }
29
30 /** * In the guide, https://docs.mongodb.com/ecosystem/use-cases/client-side-field-level-encryption-guide/, * we create the data key and then show that it is created by * retreiving it using a findOne query. Here, in implementation, we only * create the key if it doesn't already exist, ensuring we only have one * local data key. * * @param {MongoClient} client */
31 async findOrCreateDataKey(client) {
32 const encryption = new ClientEncryption(client, {
33 keyVaultNamespace: this.keyVaultNamespace,
34 kmsProviders: this.kmsProviders
35 })
36
37 await this.ensureUniqueIndexOnKeyVault(client)
38
39 let dataKey = await client
40 .db(this.keyDB)
41 .collection(this.keyColl)
42 .findOne({ keyAltNames: { $in: [this.keyAltNames] } })
43
44 if (dataKey === null) {
45 dataKey = await encryption.createDataKey("local", {
46 keyAltNames: [this.keyAltNames]
47 })
48 return dataKey.toString("base64")
49 }
50
51 return dataKey["_id"].toString("base64")
52 }
53}

The following script generates a 96-byte, locally-managed master key and saves it to a file called master-key.txt in the directory from which the script is executed, as well as saving it to our impromptu key management system in Atlas.

1// make-data-key.js
2
3const { readMasterKey, CsfleHelper } = require("./helpers");
4const { connectionString } = require("./config");
5
6async function main() {
7const localMasterKey = readMasterKey()
8
9const csfleHelper = new CsfleHelper({
10 kmsProviders: {
11 local: {
12 key: localMasterKey
13 }
14 },
15 connectionString: "PASTE YOUR MONGODB ATLAS URI HERE"
16})
17
18const client = await csfleHelper.getRegularClient()
19
20const dataKey = await csfleHelper.findOrCreateDataKey(client)
21console.log("Base64 data key. Copy and paste this into clients.js\t", dataKey)
22
23client.close()
24}
25
26main().catch(console.dir)

After saving this code, run the following to generate and save our keys.

1node make-data-key.js

And you should get this output in the terminal. Be sure to save this key, as we will be using it in our next step.

Screenshot from the terminal showing the output of running node make-data-key.js, it outputs "Base64 data key. Copy and paste this into clients.js W2Blh9teTxyORC8QT1jnzw=="

It's also a good idea to check in to make sure that this data has been saved correctly. Go to your clusters in Atlas, and navigate to your collections. You should see a new key saved in the encryption.__keyVault collection.

Screenshot of MongoDB Atlas showing that a new key has been added to our new collection.

Your key should be shaped like this:

1{
2 "_id": "UUID('27a51d69-809f-4cb9-ae15-d63f7eab1585')",
3 "keyAltNames": ["demo-data-key"],
4 "keyMaterial": "Binary('oJ6lEzjIEskH...', 0)",
5 "creationDate": "2020-11-05T23:32:26.466+00:00",
6 "updateDate": "2020-11-05T23:32:26.466+00:00",
7 "status": "0",
8 "masterKey": {
9 "provider": "local"
10 }
11}

#Defining an Extended JSON Schema Map for Fields to be Encrypted

With the data key created, we're at a point in time where we need to figure out what fields should be encrypted in a document and what fields should be left as plain text. The easiest way to do this is with a schema map.

A schema map for encryption is extended JSON and can be added directly to the Go source code or loaded from an external file. From a maintenance perspective, loading from an external file is easier to maintain.

The following table illustrates the data model of the Medical Care Management System.

Let's add a function to our csfleHelper method in helper.js file so our application knows which fields need to be encrypted and decrypted.

1if (dataKey === null) {
2 throw new Error(
3 "dataKey is a required argument. Ensure you've defined it in clients.js"
4 )
5}
6return {
7 "medicalRecords.patients": {
8 bsonType: "object",
9 // specify the encryptMetadata key at the root level of the JSON Schema.
10 // As a result, all encrypted fields defined in the properties field of the
11 // schema will inherit this encryption key unless specifically overwritten.
12 encryptMetadata: {
13 keyId: [new Binary(Buffer.from(dataKey, "base64"), 4)]
14 },
15 properties: {
16 insurance: {
17 bsonType: "object",
18 properties: {
19 // The insurance.policyNumber field is embedded inside the insurance
20 // field and represents the patient's policy number.
21 // This policy number is a distinct and sensitive field.
22 policyNumber: {
23 encrypt: {
24 bsonType: "int",
25 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic"
26 }
27 }
28 }
29 },
30 // The medicalRecords field is an array that contains a set of medical record documents.
31 // Each medical record document represents a separate visit and specifies information
32 // about the patient at that that time, such as their blood pressure, weight, and heart rate.
33 // This field is sensitive and should be encrypted.
34 medicalRecords: {
35 encrypt: {
36 bsonType: "array",
37 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Random"
38 }
39 },
40 // The bloodType field represents the patient's blood type.
41 // This field is sensitive and should be encrypted.
42 bloodType: {
43 encrypt: {
44 bsonType: "string",
45 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Random"
46 }
47 },
48 // The ssn field represents the patient's
49 // social security number. This field is
50 // sensitive and should be encrypted.
51 ssn: {
52 encrypt: {
53 bsonType: "int",
54 algorithm: "AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic"
55 }
56 }
57 }
58}

#Create the MongoDB Client

Alright, so now we have the JSON Schema and encryption keys necessary to create a CSFLE-enabled MongoDB client. Let's recap how our client will work. Our CSFLE-enabled MongoDB client will query our encrypted data, and the mongocryptd process will be automatically started by default. mongocryptd handles the following responsibilities:

  • Validates the encryption instructions defined in the JSON Schema and flags the referenced fields for encryption in read and write operations.
  • Prevents unsupported operations from being executed on encrypted fields.

To create the CSFLE-enabled client, we need to instantiate a standard MongoDB client object with the additional automatic encryption settings with the following code snippet:

1async getCsfleEnabledClient(schemaMap = null) {
2 if (schemaMap === null) {
3 throw new Error(
4 "schemaMap is a required argument. Build it using the CsfleHelper.createJsonSchemaMap method"
5 )
6 }
7 const client = new MongoClient(this.connectionString, {
8 useNewUrlParser: true,
9 useUnifiedTopology: true,
10 monitorCommands: true,
11 autoEncryption: {
12 // The key vault collection contains the data key that the client uses to encrypt and decrypt fields.
13 keyVaultNamespace: this.keyVaultNamespace,
14 // The client expects a key management system to store and provide the application's master encryption key.
15 // For now, we will use a local master key, so they use the local KMS provider.
16 kmsProviders: this.kmsProviders,
17 // The JSON Schema that we have defined doesn't explicitly specify the collection to which it applies.
18 // To assign the schema, they map it to the medicalRecords.patients collection namespace
19 schemaMap
20 }
21 })
22 return await client.connect()
23}

If the connection was successful, the client is returned.

#Perform Encrypted Read/Write Operations

We now have a CSFLE-enabled client and we can test that the client can perform queries that meet our security requirements.

#Insert a Document with Encrypted Fields

The following diagram shows the steps taken by the client application and driver to perform a write of field-level encrypted data:

Diagram that shows the data flow for a write of field-level encrypted data
Diagram that shows the data flow for a write of field-level encrypted data

We need to write a function in our clients.js to create a new patient record with the following code snippet:

Note: Clients that do not have CSFLE configured will insert unencrypted data. We recommend using server-side schema validation to enforce encrypted writes for fields that should be encrypted.

1const { readMasterKey, CsfleHelper } = require("./helpers");
2const { connectionString, dataKey } = require("./config");
3
4const localMasterKey = readMasterKey()
5
6const csfleHelper = new CsfleHelper({
7 // The client expects a key management system to store and provide the application's master encryption key. For now, we will use a local master key, so they use the local KMS provider.
8 kmsProviders: {
9 local: {
10 key: localMasterKey
11 }
12 },
13 connectionString,
14})
15
16async function main() {
17let regularClient = await csfleHelper.getRegularClient()
18let schemeMap = csfleHelper.createJsonSchemaMap(dataKey)
19let csfleClient = await csfleHelper.getCsfleEnabledClient(schemeMap)
20
21let exampleDocument = {
22 name: "Jon Doe",
23 ssn: 241014209,
24 bloodType: "AB+",
25 medicalRecords: [
26 {
27 weight: 180,
28 bloodPressure: "120/80"
29 }
30 ],
31 insurance: {
32 provider: "MaestCare",
33 policyNumber: 123142
34 }
35}
36
37const regularClientPatientsColl = regularClient
38 .db("medicalRecords")
39 .collection("patients")
40const csfleClientPatientsColl = csfleClient
41 .db("medicalRecords")
42 .collection("patients")
43
44// Performs the insert operation with the csfle-enabled client
45// We're using an update with an upsert so that subsequent runs of this script
46// don't insert new documents
47await csfleClientPatientsColl.updateOne(
48 { ssn: exampleDocument["ssn"] },
49 { $set: exampleDocument },
50 { upsert: true }
51)
52
53// Performs a read using the encrypted client, querying on an encrypted field
54const csfleFindResult = await csfleClientPatientsColl.findOne({
55 ssn: exampleDocument["ssn"]
56})
57console.log(
58 "Document retreived with csfle enabled client:\n",
59 csfleFindResult
60)
61
62// Performs a read using the regular client. We must query on a field that is
63// not encrypted.
64// Try - query on the ssn field. What is returned?
65const regularFindResult = await regularClientPatientsColl.findOne({
66 name: "Jon Doe"
67})
68console.log("Document retreived with regular client:\n", regularFindResult)
69
70await regularClient.close()
71await csfleClient.close()
72}
73
74main().catch(console.dir)

#Query for Documents on a Deterministically Encrypted Field

The following diagram shows the steps taken by the client application and driver to query and decrypt field-level encrypted data:

Diagram showing how MongoDB queries encrypted fields.

We can run queries on documents with encrypted fields using standard MongoDB driver methods. When a doctor performs a query in the Medical Care Management System to search for a patient by their SSN, the driver decrypts the patient's data before returning it:

1{
2 "_id": "5d6ecdce70401f03b27448fc",
3 "name": "Jon Doe",
4 "ssn": 241014209,
5 "bloodType": "AB+",
6 "medicalRecords": [
7 {
8 "weight": 180,
9 "bloodPressure": "120/80"
10 }
11 ],
12 "insurance": {
13 "provider": "MaestCare",
14 "policyNumber": 123142
15 }
16}

If you attempt to query your data with a MongoDB that isn't configured with the correct key, this is what you will see:

screenshot of the terminal that shows that encrypted feilds cannot be read from a regualr client.

And you should see your data written to your MongoDB Atlas database:

Screenshot from MongoDB Atlas showing that new encrypted medical records have been written to the databse.

#Running in Docker

If you run into any issues running your code locally, I have developed a Docker image that you can use to help you get setup quickly or to troubleshoot local configuration issues. You can download the code here. Make sure you have docker configured locally before you run the code. You can download Docker here.

  1. Change directories to the Docker directory.
1cd docker
  1. Build Docker image with a tag name. Within this directory, execute:
1docker build . -t mdb-csfle-example

This will build a Docker image with a tag name mdb-csfle-example.

  1. Run the Docker image by executing:
1docker run -tih csfle mdb-csfle-example

The command above will run a Docker image with tag mdb-csfle-example and provide it with csfle as its hostname.

  1. Once you're inside the Docker container, you can follow the below steps to run the NodeJS code example.
1$ export MONGODB_URL="mongodb+srv://USER:PWD@EXAMPLE.mongodb.net/dbname?retryWrites=true&w=majority"
2
3$ node ./example.js

Note: If you're connecting to MongoDB Atlas, please make sure to Configure Allowlist Entries.

#Summary

We wanted to develop a system that securely stores sensitive medical records for patients. We also wanted strong data access and security guarantees that do not rely on individual users. After researching the available options, we determined that MongoDB Client-Side Field Level Encryption satisfies their requirements and decided to implement it in their application. To implement CSFLE, we did the following:

1. Created a Locally-Managed Master Encryption Key

A locally-managed master key allowed us to rapidly develop the client application without external dependencies and avoid accidentally leaking sensitive production credentials.

2. Generated an Encrypted Data Key with the Master Key

CSFLE uses envelope encryption, so we generated a data key that encrypts and decrypts each field and then encrypted the data key using a master key. This allows us to store the encrypted data key in MongoDB so that it is shared with all clients while preventing access to clients that don't have access to the master key.

3. Created a JSON Schema

CSFLE can automatically encrypt and decrypt fields based on a provided JSON Schema that specifies which fields to encrypt and how to encrypt them.

4. Tested and Validated Queries with the CSFLE Client

We tested their CSFLE implementation by inserting and querying documents with encrypted fields. We then validated that clients without CSFLE enabled could not read the encrypted data.

#Move to Production

In this guide, we stored the master key in your local file system. Since your data encryption keys would be readable by anyone that gains direct access to your master key, we strongly recommend that you use a more secure storage location such as a Key Management System (KMS).

#Further Reading

For more information on client-side field level encryption in MongoDB, check out the reference docs in the server manual:

Rate this article
MongoDB Icon
  • Developer Hub
  • Documentation
  • University
  • Community Forums

© MongoDB, Inc.