Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MongoDB 系列 - Node.js 结合 MongoDB 实现字段级自动加密 #38

Open
qufei1993 opened this issue May 4, 2022 · 0 comments
Open
Assignees
Labels

Comments

@qufei1993
Copy link
Owner


某些场景下,对于数据隐私会有较高的要求,例如,用户系统的个人信息(身份证、手机号)、医患系统的患者信息等,怎么用技术手段安全的保护这些敏感数据是我们开发人员需要考虑的问题

本篇文章,将介绍 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 驱动程序自动加密/解密时需要访问事先创建的数据加密密钥,而这个密钥经过程序的处理会存储在密钥保管数据库的集合中,以下是创建一个数据加密密钥的交互图。
image.png

创建主密钥

创建 MongoDB 数据加密密钥还需要另外一个称为 “主密钥” 的密钥进行加密,下图展示了创建主密钥的流程:
image.png
主密钥的存储,生产环境 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 格式编码。
image.png

JSON Schema 定义

Node.js 驱动程序使用 JSON Schema 定义集合需要加密的字段,文档类型定义使用 BSON 类型。

/**
 * 使用 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 服务器交互,读取/写入带有加密字段的数据。

读写操作流程图

下图展示了客户端应用程序和驱动程序为写入字段级加密数据的一个步骤:
image.png
下图展示了客户端应用程序和驱动程序为读取加密后字段进行解密操作的一个过程:
image.png

创建 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

完整代码:

// 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);
}

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

// 配置
const config = {
  connectionString: 'mongodb://localhost:27017',
  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,
  },
};

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

  return client.connect();
}

/**
 * 获取或创建数据加密密钥
 * 如果已存在 dataKey 则返回,否则创建一条 dataKey
 */
async function getOrCreateDataKey(regularClient, kmsProviders) {
  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');
}

/**
 * 使用 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;
}

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 {
    // 创建常规 MongoDB 客户端
    const regularClient = await getRegularClient(config.connectionString);

    // 获取数据密钥
    const base64DataKeyId = await getOrCreateDataKey(regularClient, kmsProviders);

    // 使用 JSON Schema 定义集合需要加密的字段
    const schemaMap = getSchemaMap(base64DataKeyId);

    // 创建加密 MongoDB 客户端
    const csfleClient = await getCSFLEClient(schemaMap, kmsProviders);
    
    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('user information after decrypteduser info: ', result);
  } catch (err) {
    console.error(err);
  }
})();
@qufei1993 qufei1993 self-assigned this May 4, 2022
@qufei1993 qufei1993 changed the title Node.js 结合 MongoDB 实现字段级自动加密 MongoDB 系列 - Node.js 结合 MongoDB 实现字段级自动加密 May 4, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant