You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

363 lines
17 KiB
JavaScript

"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