"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.StateMachine = void 0; const fs = require("fs/promises"); const net = require("net"); const tls = require("tls"); const bson_1 = require("../bson"); const deps_1 = require("../deps"); const utils_1 = require("../utils"); const errors_1 = require("./errors"); let socks = null; function loadSocks() { if (socks == null) { const socksImport = (0, deps_1.getSocks)(); if ('kModuleError' in socksImport) { throw socksImport.kModuleError; } socks = socksImport; } return socks; } // libmongocrypt states const MONGOCRYPT_CTX_ERROR = 0; const MONGOCRYPT_CTX_NEED_MONGO_COLLINFO = 1; const MONGOCRYPT_CTX_NEED_MONGO_MARKINGS = 2; const MONGOCRYPT_CTX_NEED_MONGO_KEYS = 3; const MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS = 7; const MONGOCRYPT_CTX_NEED_KMS = 4; const MONGOCRYPT_CTX_READY = 5; const MONGOCRYPT_CTX_DONE = 6; const HTTPS_PORT = 443; const stateToString = new Map([ [MONGOCRYPT_CTX_ERROR, 'MONGOCRYPT_CTX_ERROR'], [MONGOCRYPT_CTX_NEED_MONGO_COLLINFO, 'MONGOCRYPT_CTX_NEED_MONGO_COLLINFO'], [MONGOCRYPT_CTX_NEED_MONGO_MARKINGS, 'MONGOCRYPT_CTX_NEED_MONGO_MARKINGS'], [MONGOCRYPT_CTX_NEED_MONGO_KEYS, 'MONGOCRYPT_CTX_NEED_MONGO_KEYS'], [MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS, 'MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS'], [MONGOCRYPT_CTX_NEED_KMS, 'MONGOCRYPT_CTX_NEED_KMS'], [MONGOCRYPT_CTX_READY, 'MONGOCRYPT_CTX_READY'], [MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE'] ]); const INSECURE_TLS_OPTIONS = [ 'tlsInsecure', 'tlsAllowInvalidCertificates', 'tlsAllowInvalidHostnames', // These options are disallowed by the spec, so we explicitly filter them out if provided, even // though the StateMachine does not declare support for these options. 'tlsDisableOCSPEndpointCheck', 'tlsDisableCertificateRevocationCheck' ]; /** * Helper function for logging. Enabled by setting the environment flag MONGODB_CRYPT_DEBUG. * @param msg - Anything you want to be logged. */ function debug(msg) { if (process.env.MONGODB_CRYPT_DEBUG) { // eslint-disable-next-line no-console console.error(msg); } } /** * @internal * An internal class that executes across a MongoCryptContext until either * a finishing state or an error is reached. Do not instantiate directly. */ class StateMachine { constructor(options, bsonOptions = (0, bson_1.pluckBSONSerializeOptions)(options)) { this.options = options; this.bsonOptions = bsonOptions; } /** * Executes the state machine according to the specification */ async execute(executor, context) { const keyVaultNamespace = executor._keyVaultNamespace; const keyVaultClient = executor._keyVaultClient; const metaDataClient = executor._metaDataClient; const mongocryptdClient = executor._mongocryptdClient; const mongocryptdManager = executor._mongocryptdManager; let result = null; while (context.state !== MONGOCRYPT_CTX_DONE && context.state !== MONGOCRYPT_CTX_ERROR) { debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`); switch (context.state) { case MONGOCRYPT_CTX_NEED_MONGO_COLLINFO: { const filter = (0, bson_1.deserialize)(context.nextMongoOperation()); if (!metaDataClient) { throw new errors_1.MongoCryptError('unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_COLLINFO but metadata client is undefined'); } const collInfo = await this.fetchCollectionInfo(metaDataClient, context.ns, filter); if (collInfo) { context.addMongoOperationResponse(collInfo); } context.finishMongoOperation(); break; } case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: { const command = context.nextMongoOperation(); if (!mongocryptdClient) { throw new errors_1.MongoCryptError('unreachable state machine state: entered MONGOCRYPT_CTX_NEED_MONGO_MARKINGS but mongocryptdClient is undefined'); } // When we are using the shared library, we don't have a mongocryptd manager. const markedCommand = mongocryptdManager ? await mongocryptdManager.withRespawn(this.markCommand.bind(this, mongocryptdClient, context.ns, command)) : await this.markCommand(mongocryptdClient, context.ns, command); context.addMongoOperationResponse(markedCommand); context.finishMongoOperation(); break; } case MONGOCRYPT_CTX_NEED_MONGO_KEYS: { const filter = context.nextMongoOperation(); const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter); if (keys.length === 0) { // This is kind of a hack. For `rewrapManyDataKey`, we have tests that // guarantee that when there are no matching keys, `rewrapManyDataKey` returns // nothing. We also have tests for auto encryption that guarantee for `encrypt` // we return an error when there are no matching keys. This error is generated in // subsequent iterations of the state machine. // Some apis (`encrypt`) throw if there are no filter matches and others (`rewrapManyDataKey`) // do not. We set the result manually here, and let the state machine continue. `libmongocrypt` // will inform us if we need to error by setting the state to `MONGOCRYPT_CTX_ERROR` but // otherwise we'll return `{ v: [] }`. result = { v: [] }; } for await (const key of keys) { context.addMongoOperationResponse((0, bson_1.serialize)(key)); } context.finishMongoOperation(); break; } case MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS: { const kmsProviders = await executor.askForKMSCredentials(); context.provideKMSProviders((0, bson_1.serialize)(kmsProviders)); break; } case MONGOCRYPT_CTX_NEED_KMS: { const requests = Array.from(this.requests(context)); await Promise.all(requests); context.finishKMSRequests(); break; } case MONGOCRYPT_CTX_READY: { const finalizedContext = context.finalize(); // @ts-expect-error finalize can change the state, check for error if (context.state === MONGOCRYPT_CTX_ERROR) { const message = context.status.message || 'Finalization error'; throw new errors_1.MongoCryptError(message); } result = (0, bson_1.deserialize)(finalizedContext, this.options); break; } default: throw new errors_1.MongoCryptError(`Unknown state: ${context.state}`); } } if (context.state === MONGOCRYPT_CTX_ERROR || result == null) { const message = context.status.message; if (!message) { debug(`unidentifiable error in MongoCrypt - received an error status from \`libmongocrypt\` but received no error message.`); } throw new errors_1.MongoCryptError(message ?? 'unidentifiable error in MongoCrypt - received an error status from `libmongocrypt` but received no error message.'); } return result; } /** * Handles the request to the KMS service. Exposed for testing purposes. Do not directly invoke. * @param kmsContext - A C++ KMS context returned from the bindings * @returns A promise that resolves when the KMS reply has be fully parsed */ kmsRequest(request) { const parsedUrl = request.endpoint.split(':'); const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT; const options = { host: parsedUrl[0], servername: parsedUrl[0], port }; const message = request.message; // TODO(NODE-3959): We can adopt `for-await on(socket, 'data')` with logic to control abort // eslint-disable-next-line @typescript-eslint/no-misused-promises, no-async-promise-executor return new Promise(async (resolve, reject) => { const buffer = new utils_1.BufferPool(); // eslint-disable-next-line prefer-const let socket; let rawSocket; function destroySockets() { for (const sock of [socket, rawSocket]) { if (sock) { sock.removeAllListeners(); sock.destroy(); } } } function ontimeout() { destroySockets(); reject(new errors_1.MongoCryptError('KMS request timed out')); } function onerror(err) { destroySockets(); const mcError = new errors_1.MongoCryptError('KMS request failed', { cause: err }); reject(mcError); } if (this.options.proxyOptions && this.options.proxyOptions.proxyHost) { rawSocket = net.connect({ host: this.options.proxyOptions.proxyHost, port: this.options.proxyOptions.proxyPort || 1080 }); rawSocket.on('timeout', ontimeout); rawSocket.on('error', onerror); try { // eslint-disable-next-line @typescript-eslint/no-var-requires const events = require('events'); await events.once(rawSocket, 'connect'); socks ??= loadSocks(); options.socket = (await socks.SocksClient.createConnection({ existing_socket: rawSocket, command: 'connect', destination: { host: options.host, port: options.port }, proxy: { // host and port are ignored because we pass existing_socket host: 'iLoveJavaScript', port: 0, type: 5, userId: this.options.proxyOptions.proxyUsername, password: this.options.proxyOptions.proxyPassword } })).socket; } catch (err) { return onerror(err); } } const tlsOptions = this.options.tlsOptions; if (tlsOptions) { const kmsProvider = request.kmsProvider; const providerTlsOptions = tlsOptions[kmsProvider]; if (providerTlsOptions) { const error = this.validateTlsOptions(kmsProvider, providerTlsOptions); if (error) reject(error); try { await this.setTlsOptions(providerTlsOptions, options); } catch (error) { return onerror(error); } } } socket = tls.connect(options, () => { socket.write(message); }); socket.once('timeout', ontimeout); socket.once('error', onerror); socket.on('data', data => { buffer.append(data); while (request.bytesNeeded > 0 && buffer.length) { const bytesNeeded = Math.min(request.bytesNeeded, buffer.length); request.addResponse(buffer.read(bytesNeeded)); } if (request.bytesNeeded <= 0) { // There's no need for any more activity on this socket at this point. destroySockets(); resolve(); } }); }); } *requests(context) { for (let request = context.nextKMSRequest(); request != null; request = context.nextKMSRequest()) { yield this.kmsRequest(request); } } /** * Validates the provided TLS options are secure. * * @param kmsProvider - The KMS provider name. * @param tlsOptions - The client TLS options for the provider. * * @returns An error if any option is invalid. */ validateTlsOptions(kmsProvider, tlsOptions) { const tlsOptionNames = Object.keys(tlsOptions); for (const option of INSECURE_TLS_OPTIONS) { if (tlsOptionNames.includes(option)) { return new errors_1.MongoCryptError(`Insecure TLS options prohibited for ${kmsProvider}: ${option}`); } } } /** * Sets only the valid secure TLS options. * * @param tlsOptions - The client TLS options for the provider. * @param options - The existing connection options. */ async setTlsOptions(tlsOptions, options) { if (tlsOptions.tlsCertificateKeyFile) { const cert = await fs.readFile(tlsOptions.tlsCertificateKeyFile); options.cert = options.key = cert; } if (tlsOptions.tlsCAFile) { options.ca = await fs.readFile(tlsOptions.tlsCAFile); } if (tlsOptions.tlsCertificateKeyFilePassword) { options.passphrase = tlsOptions.tlsCertificateKeyFilePassword; } } /** * Fetches collection info for a provided namespace, when libmongocrypt * enters the `MONGOCRYPT_CTX_NEED_MONGO_COLLINFO` state. The result is * used to inform libmongocrypt of the schema associated with this * namespace. Exposed for testing purposes. Do not directly invoke. * * @param client - A MongoClient connected to the topology * @param ns - The namespace to list collections from * @param filter - A filter for the listCollections command * @param callback - Invoked with the info of the requested collection, or with an error */ async fetchCollectionInfo(client, ns, filter) { const { db } = utils_1.MongoDBCollectionNamespace.fromString(ns); const collections = await client .db(db) .listCollections(filter, { promoteLongs: false, promoteValues: false }) .toArray(); const info = collections.length > 0 ? (0, bson_1.serialize)(collections[0]) : null; return info; } /** * Calls to the mongocryptd to provide markings for a command. * Exposed for testing purposes. Do not directly invoke. * @param client - A MongoClient connected to a mongocryptd * @param ns - The namespace (database.collection) the command is being executed on * @param command - The command to execute. * @param callback - Invoked with the serialized and marked bson command, or with an error */ async markCommand(client, ns, command) { const options = { promoteLongs: false, promoteValues: false }; const { db } = utils_1.MongoDBCollectionNamespace.fromString(ns); const rawCommand = (0, bson_1.deserialize)(command, options); const response = await client.db(db).command(rawCommand, options); return (0, bson_1.serialize)(response, this.bsonOptions); } /** * Requests keys from the keyVault collection on the topology. * Exposed for testing purposes. Do not directly invoke. * @param client - A MongoClient connected to the topology * @param keyVaultNamespace - The namespace (database.collection) of the keyVault Collection * @param filter - The filter for the find query against the keyVault Collection * @param callback - Invoked with the found keys, or with an error */ fetchKeys(client, keyVaultNamespace, filter) { const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(keyVaultNamespace); return client .db(dbName) .collection(collectionName, { readConcern: { level: 'majority' } }) .find((0, bson_1.deserialize)(filter)) .toArray(); } } exports.StateMachine = StateMachine; //# sourceMappingURL=state_machine.js.map