Node.js 结合 MongoDB 实现字段级自动加密

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

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

创建主密钥

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

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

读写操作流程图

下图展示了客户端应用程序和驱动程序为写入字段级加密数据的一个步骤:32ab73c378f463ad38667058968bcf4f.png下图展示了客户端应用程序和驱动程序为读取加密后字段进行解密操作的一个过程:a3bd623db90327d642d9490584826d67.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 工具查询该集合,可以看到需要的字段都已经做了加密,尽管我是一个管理员能够查看数据,也无法查看这些隐私数据。

fddb37e5d32af45c01c5220ebcd1797a.png
image.png

只能通过程序正确的创建了 CSFLE 的客户端才能读取出解密后的数据。

d92c67ac3e8211adf9e3f369396c1d3d.png
image.png

几个常见错误

文中示例测试时常见的几个错误,可以做为参考。

认证失败

遇到 Authentication failed 错误,基本上都是连接字符串的账号密码或权限错误,使用 MongoDB Atlas 的需要检查下数据库的访问权限配置

a38f6af72a5006963a385d913443ecaf.png
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/

篇幅有限,阅读原文,查看文中示例代码。

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。

bab4eb434233348a2a74e57c9823f3dd.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值