state_machine.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", { value: true });
  3. exports.StateMachine = void 0;
  4. const fs = require("fs/promises");
  5. const net = require("net");
  6. const tls = require("tls");
  7. const bson_1 = require("../bson");
  8. const deps_1 = require("../deps");
  9. const utils_1 = require("../utils");
  10. const errors_1 = require("./errors");
  11. let socks = null;
  12. function loadSocks() {
  13. if (socks == null) {
  14. const socksImport = (0, deps_1.getSocks)();
  15. if ('kModuleError' in socksImport) {
  16. throw socksImport.kModuleError;
  17. }
  18. socks = socksImport;
  19. }
  20. return socks;
  21. }
  22. // libmongocrypt states
  23. const MONGOCRYPT_CTX_ERROR = 0;
  24. const MONGOCRYPT_CTX_NEED_MONGO_COLLINFO = 1;
  25. const MONGOCRYPT_CTX_NEED_MONGO_MARKINGS = 2;
  26. const MONGOCRYPT_CTX_NEED_MONGO_KEYS = 3;
  27. const MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS = 7;
  28. const MONGOCRYPT_CTX_NEED_KMS = 4;
  29. const MONGOCRYPT_CTX_READY = 5;
  30. const MONGOCRYPT_CTX_DONE = 6;
  31. const HTTPS_PORT = 443;
  32. const stateToString = new Map([
  33. [MONGOCRYPT_CTX_ERROR, 'MONGOCRYPT_CTX_ERROR'],
  34. [MONGOCRYPT_CTX_NEED_MONGO_COLLINFO, 'MONGOCRYPT_CTX_NEED_MONGO_COLLINFO'],
  35. [MONGOCRYPT_CTX_NEED_MONGO_MARKINGS, 'MONGOCRYPT_CTX_NEED_MONGO_MARKINGS'],
  36. [MONGOCRYPT_CTX_NEED_MONGO_KEYS, 'MONGOCRYPT_CTX_NEED_MONGO_KEYS'],
  37. [MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS, 'MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS'],
  38. [MONGOCRYPT_CTX_NEED_KMS, 'MONGOCRYPT_CTX_NEED_KMS'],
  39. [MONGOCRYPT_CTX_READY, 'MONGOCRYPT_CTX_READY'],
  40. [MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE']
  41. ]);
  42. const INSECURE_TLS_OPTIONS = [
  43. 'tlsInsecure',
  44. 'tlsAllowInvalidCertificates',
  45. 'tlsAllowInvalidHostnames',
  46. // These options are disallowed by the spec, so we explicitly filter them out if provided, even
  47. // though the StateMachine does not declare support for these options.
  48. 'tlsDisableOCSPEndpointCheck',
  49. 'tlsDisableCertificateRevocationCheck'
  50. ];
  51. /**
  52. * Helper function for logging. Enabled by setting the environment flag MONGODB_CRYPT_DEBUG.
  53. * @param msg - Anything you want to be logged.
  54. */
  55. function debug(msg) {
  56. if (process.env.MONGODB_CRYPT_DEBUG) {
  57. // eslint-disable-next-line no-console
  58. console.error(msg);
  59. }
  60. }
  61. /**
  62. * @internal
  63. * An internal class that executes across a MongoCryptContext until either
  64. * a finishing state or an error is reached. Do not instantiate directly.
  65. */
  66. class StateMachine {
  67. constructor(options, bsonOptions = (0, bson_1.pluckBSONSerializeOptions)(options)) {
  68. this.options = options;
  69. this.bsonOptions = bsonOptions;
  70. }
  71. /**
  72. * Executes the state machine according to the specification
  73. */
  74. async execute(executor, context) {
  75. const keyVaultNamespace = executor._keyVaultNamespace;
  76. const keyVaultClient = executor._keyVaultClient;
  77. const metaDataClient = executor._metaDataClient;
  78. const mongocryptdClient = executor._mongocryptdClient;
  79. const mongocryptdManager = executor._mongocryptdManager;
  80. let result = null;
  81. while (context.state !== MONGOCRYPT_CTX_DONE && context.state !== MONGOCRYPT_CTX_ERROR) {
  82. debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`);
  83. switch (context.state) {
  84. case MONGOCRYPT_CTX_NEED_MONGO_COLLINFO: {
  85. const filter = (0, bson_1.deserialize)(context.nextMongoOperation());
  86. if (!metaDataClient) {
  87. throw new errors_1.MongoCryptError('unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_COLLINFO but metadata client is undefined');
  88. }
  89. const collInfo = await this.fetchCollectionInfo(metaDataClient, context.ns, filter);
  90. if (collInfo) {
  91. context.addMongoOperationResponse(collInfo);
  92. }
  93. context.finishMongoOperation();
  94. break;
  95. }
  96. case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: {
  97. const command = context.nextMongoOperation();
  98. if (!mongocryptdClient) {
  99. throw new errors_1.MongoCryptError('unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_MARKINGS but mongocryptdClient is undefined');
  100. }
  101. // When we are using the shared library, we don't have a mongocryptd manager.
  102. const markedCommand = mongocryptdManager
  103. ? await mongocryptdManager.withRespawn(this.markCommand.bind(this, mongocryptdClient, context.ns, command))
  104. : await this.markCommand(mongocryptdClient, context.ns, command);
  105. context.addMongoOperationResponse(markedCommand);
  106. context.finishMongoOperation();
  107. break;
  108. }
  109. case MONGOCRYPT_CTX_NEED_MONGO_KEYS: {
  110. const filter = context.nextMongoOperation();
  111. const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter);
  112. if (keys.length === 0) {
  113. // This is kind of a hack. For `rewrapManyDataKey`, we have tests that
  114. // guarantee that when there are no matching keys, `rewrapManyDataKey` returns
  115. // nothing. We also have tests for auto encryption that guarantee for `encrypt`
  116. // we return an error when there are no matching keys. This error is generated in
  117. // subsequent iterations of the state machine.
  118. // Some apis (`encrypt`) throw if there are no filter matches and others (`rewrapManyDataKey`)
  119. // do not. We set the result manually here, and let the state machine continue. `libmongocrypt`
  120. // will inform us if we need to error by setting the state to `MONGOCRYPT_CTX_ERROR` but
  121. // otherwise we'll return `{ v: [] }`.
  122. result = { v: [] };
  123. }
  124. for await (const key of keys) {
  125. context.addMongoOperationResponse((0, bson_1.serialize)(key));
  126. }
  127. context.finishMongoOperation();
  128. break;
  129. }
  130. case MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS: {
  131. const kmsProviders = await executor.askForKMSCredentials();
  132. context.provideKMSProviders((0, bson_1.serialize)(kmsProviders));
  133. break;
  134. }
  135. case MONGOCRYPT_CTX_NEED_KMS: {
  136. const requests = Array.from(this.requests(context));
  137. await Promise.all(requests);
  138. context.finishKMSRequests();
  139. break;
  140. }
  141. case MONGOCRYPT_CTX_READY: {
  142. const finalizedContext = context.finalize();
  143. // @ts-expect-error finalize can change the state, check for error
  144. if (context.state === MONGOCRYPT_CTX_ERROR) {
  145. const message = context.status.message || 'Finalization error';
  146. throw new errors_1.MongoCryptError(message);
  147. }
  148. result = (0, bson_1.deserialize)(finalizedContext, this.options);
  149. break;
  150. }
  151. default:
  152. throw new errors_1.MongoCryptError(`Unknown state: ${context.state}`);
  153. }
  154. }
  155. if (context.state === MONGOCRYPT_CTX_ERROR || result == null) {
  156. const message = context.status.message;
  157. if (!message) {
  158. debug(`unidentifiable error in MongoCrypt - received an error status from \`libmongocrypt\` but received no error message.`);
  159. }
  160. throw new errors_1.MongoCryptError(message ??
  161. 'unidentifiable error in MongoCrypt - received an error status from `libmongocrypt` but received no error message.');
  162. }
  163. return result;
  164. }
  165. /**
  166. * Handles the request to the KMS service. Exposed for testing purposes. Do not directly invoke.
  167. * @param kmsContext - A C++ KMS context returned from the bindings
  168. * @returns A promise that resolves when the KMS reply has be fully parsed
  169. */
  170. kmsRequest(request) {
  171. const parsedUrl = request.endpoint.split(':');
  172. const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT;
  173. const options = {
  174. host: parsedUrl[0],
  175. servername: parsedUrl[0],
  176. port
  177. };
  178. const message = request.message;
  179. // TODO(NODE-3959): We can adopt `for-await on(socket, 'data')` with logic to control abort
  180. // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor
  181. return new Promise(async (resolve, reject) => {
  182. const buffer = new utils_1.BufferPool();
  183. // eslint-disable-next-line prefer-const
  184. let socket;
  185. let rawSocket;
  186. function destroySockets() {
  187. for (const sock of [socket, rawSocket]) {
  188. if (sock) {
  189. sock.removeAllListeners();
  190. sock.destroy();
  191. }
  192. }
  193. }
  194. function ontimeout() {
  195. destroySockets();
  196. reject(new errors_1.MongoCryptError('KMS request timed out'));
  197. }
  198. function onerror(err) {
  199. destroySockets();
  200. const mcError = new errors_1.MongoCryptError('KMS request failed', { cause: err });
  201. reject(mcError);
  202. }
  203. if (this.options.proxyOptions && this.options.proxyOptions.proxyHost) {
  204. rawSocket = net.connect({
  205. host: this.options.proxyOptions.proxyHost,
  206. port: this.options.proxyOptions.proxyPort || 1080
  207. });
  208. rawSocket.on('timeout', ontimeout);
  209. rawSocket.on('error', onerror);
  210. try {
  211. // eslint-disable-next-line @typescript-eslint/no-var-requires
  212. const events = require('events');
  213. await events.once(rawSocket, 'connect');
  214. socks ??= loadSocks();
  215. options.socket = (await socks.SocksClient.createConnection({
  216. existing_socket: rawSocket,
  217. command: 'connect',
  218. destination: { host: options.host, port: options.port },
  219. proxy: {
  220. // host and port are ignored because we pass existing_socket
  221. host: 'iLoveJavaScript',
  222. port: 0,
  223. type: 5,
  224. userId: this.options.proxyOptions.proxyUsername,
  225. password: this.options.proxyOptions.proxyPassword
  226. }
  227. })).socket;
  228. }
  229. catch (err) {
  230. return onerror(err);
  231. }
  232. }
  233. const tlsOptions = this.options.tlsOptions;
  234. if (tlsOptions) {
  235. const kmsProvider = request.kmsProvider;
  236. const providerTlsOptions = tlsOptions[kmsProvider];
  237. if (providerTlsOptions) {
  238. const error = this.validateTlsOptions(kmsProvider, providerTlsOptions);
  239. if (error)
  240. reject(error);
  241. try {
  242. await this.setTlsOptions(providerTlsOptions, options);
  243. }
  244. catch (error) {
  245. return onerror(error);
  246. }
  247. }
  248. }
  249. socket = tls.connect(options, () => {
  250. socket.write(message);
  251. });
  252. socket.once('timeout', ontimeout);
  253. socket.once('error', onerror);
  254. socket.on('data', data => {
  255. buffer.append(data);
  256. while (request.bytesNeeded > 0 && buffer.length) {
  257. const bytesNeeded = Math.min(request.bytesNeeded, buffer.length);
  258. request.addResponse(buffer.read(bytesNeeded));
  259. }
  260. if (request.bytesNeeded <= 0) {
  261. // There's no need for any more activity on this socket at this point.
  262. destroySockets();
  263. resolve();
  264. }
  265. });
  266. });
  267. }
  268. *requests(context) {
  269. for (let request = context.nextKMSRequest(); request != null; request = context.nextKMSRequest()) {
  270. yield this.kmsRequest(request);
  271. }
  272. }
  273. /**
  274. * Validates the provided TLS options are secure.
  275. *
  276. * @param kmsProvider - The KMS provider name.
  277. * @param tlsOptions - The client TLS options for the provider.
  278. *
  279. * @returns An error if any option is invalid.
  280. */
  281. validateTlsOptions(kmsProvider, tlsOptions) {
  282. const tlsOptionNames = Object.keys(tlsOptions);
  283. for (const option of INSECURE_TLS_OPTIONS) {
  284. if (tlsOptionNames.includes(option)) {
  285. return new errors_1.MongoCryptError(`Insecure TLS options prohibited for ${kmsProvider}: ${option}`);
  286. }
  287. }
  288. }
  289. /**
  290. * Sets only the valid secure TLS options.
  291. *
  292. * @param tlsOptions - The client TLS options for the provider.
  293. * @param options - The existing connection options.
  294. */
  295. async setTlsOptions(tlsOptions, options) {
  296. if (tlsOptions.tlsCertificateKeyFile) {
  297. const cert = await fs.readFile(tlsOptions.tlsCertificateKeyFile);
  298. options.cert = options.key = cert;
  299. }
  300. if (tlsOptions.tlsCAFile) {
  301. options.ca = await fs.readFile(tlsOptions.tlsCAFile);
  302. }
  303. if (tlsOptions.tlsCertificateKeyFilePassword) {
  304. options.passphrase = tlsOptions.tlsCertificateKeyFilePassword;
  305. }
  306. }
  307. /**
  308. * Fetches collection info for a provided namespace, when libmongocrypt
  309. * enters the `MONGOCRYPT_CTX_NEED_MONGO_COLLINFO` state. The result is
  310. * used to inform libmongocrypt of the schema associated with this
  311. * namespace. Exposed for testing purposes. Do not directly invoke.
  312. *
  313. * @param client - A MongoClient connected to the topology
  314. * @param ns - The namespace to list collections from
  315. * @param filter - A filter for the listCollections command
  316. * @param callback - Invoked with the info of the requested collection, or with an error
  317. */
  318. async fetchCollectionInfo(client, ns, filter) {
  319. const { db } = utils_1.MongoDBCollectionNamespace.fromString(ns);
  320. const collections = await client
  321. .db(db)
  322. .listCollections(filter, {
  323. promoteLongs: false,
  324. promoteValues: false
  325. })
  326. .toArray();
  327. const info = collections.length > 0 ? (0, bson_1.serialize)(collections[0]) : null;
  328. return info;
  329. }
  330. /**
  331. * Calls to the mongocryptd to provide markings for a command.
  332. * Exposed for testing purposes. Do not directly invoke.
  333. * @param client - A MongoClient connected to a mongocryptd
  334. * @param ns - The namespace (database.collection) the command is being executed on
  335. * @param command - The command to execute.
  336. * @param callback - Invoked with the serialized and marked bson command, or with an error
  337. */
  338. async markCommand(client, ns, command) {
  339. const options = { promoteLongs: false, promoteValues: false };
  340. const { db } = utils_1.MongoDBCollectionNamespace.fromString(ns);
  341. const rawCommand = (0, bson_1.deserialize)(command, options);
  342. const response = await client.db(db).command(rawCommand, options);
  343. return (0, bson_1.serialize)(response, this.bsonOptions);
  344. }
  345. /**
  346. * Requests keys from the keyVault collection on the topology.
  347. * Exposed for testing purposes. Do not directly invoke.
  348. * @param client - A MongoClient connected to the topology
  349. * @param keyVaultNamespace - The namespace (database.collection) of the keyVault Collection
  350. * @param filter - The filter for the find query against the keyVault Collection
  351. * @param callback - Invoked with the found keys, or with an error
  352. */
  353. fetchKeys(client, keyVaultNamespace, filter) {
  354. const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(keyVaultNamespace);
  355. return client
  356. .db(dbName)
  357. .collection(collectionName, { readConcern: { level: 'majority' } })
  358. .find((0, bson_1.deserialize)(filter))
  359. .toArray();
  360. }
  361. }
  362. exports.StateMachine = StateMachine;
  363. //# sourceMappingURL=state_machine.js.map