You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
426 lines
19 KiB
426 lines
19 KiB
"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 abstract_cursor_1 = require("../cursor/abstract_cursor"); |
|
const deps_1 = require("../deps"); |
|
const error_1 = require("../error"); |
|
const timeout_1 = require("../timeout"); |
|
const utils_1 = require("../utils"); |
|
const client_encryption_1 = require("./client_encryption"); |
|
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); |
|
} |
|
} |
|
/** |
|
* 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: [] }`. |
|
*/ |
|
let EMPTY_V; |
|
/** |
|
* @internal |
|
* An internal class that executes across a MongoCryptContext until either |
|
* a finishing state or an error is reached. Do not instantiate directly. |
|
*/ |
|
// TODO(DRIVERS-2671): clarify CSOT behavior for FLE APIs |
|
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, options) { |
|
const keyVaultNamespace = executor._keyVaultNamespace; |
|
const keyVaultClient = executor._keyVaultClient; |
|
const metaDataClient = executor._metaDataClient; |
|
const mongocryptdClient = executor._mongocryptdClient; |
|
const mongocryptdManager = executor._mongocryptdManager; |
|
let result = null; |
|
// Typescript treats getters just like properties: Once you've tested it for equality |
|
// it cannot change. Which is exactly the opposite of what we use state and status for. |
|
// Every call to at least `addMongoOperationResponse` and `finalize` can change the state. |
|
// These wrappers let us write code more naturally and not add compiler exceptions |
|
// to conditions checks inside the state machine. |
|
const getStatus = () => context.status; |
|
const getState = () => context.state; |
|
while (getState() !== MONGOCRYPT_CTX_DONE && getState() !== MONGOCRYPT_CTX_ERROR) { |
|
options.signal?.throwIfAborted(); |
|
debug(`[context#${context.id}] ${stateToString.get(getState()) || getState()}`); |
|
switch (getState()) { |
|
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 collInfoCursor = this.fetchCollectionInfo(metaDataClient, context.ns, filter, options); |
|
for await (const collInfo of collInfoCursor) { |
|
context.addMongoOperationResponse((0, bson_1.serialize)(collInfo)); |
|
if (getState() === MONGOCRYPT_CTX_ERROR) |
|
break; |
|
} |
|
if (getState() === MONGOCRYPT_CTX_ERROR) |
|
break; |
|
context.finishMongoOperation(); |
|
break; |
|
} |
|
case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: { |
|
const command = context.nextMongoOperation(); |
|
if (getState() === MONGOCRYPT_CTX_ERROR) |
|
break; |
|
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, options)) |
|
: await this.markCommand(mongocryptdClient, context.ns, command, options); |
|
context.addMongoOperationResponse(markedCommand); |
|
context.finishMongoOperation(); |
|
break; |
|
} |
|
case MONGOCRYPT_CTX_NEED_MONGO_KEYS: { |
|
const filter = context.nextMongoOperation(); |
|
const keys = await this.fetchKeys(keyVaultClient, keyVaultNamespace, filter, options); |
|
if (keys.length === 0) { |
|
// See docs on EMPTY_V |
|
result = EMPTY_V ??= (0, bson_1.serialize)({ 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: { |
|
await Promise.all(this.requests(context, options)); |
|
context.finishKMSRequests(); |
|
break; |
|
} |
|
case MONGOCRYPT_CTX_READY: { |
|
const finalizedContext = context.finalize(); |
|
if (getState() === MONGOCRYPT_CTX_ERROR) { |
|
const message = getStatus().message || 'Finalization error'; |
|
throw new errors_1.MongoCryptError(message); |
|
} |
|
result = finalizedContext; |
|
break; |
|
} |
|
default: |
|
throw new errors_1.MongoCryptError(`Unknown state: ${getState()}`); |
|
} |
|
} |
|
if (getState() === MONGOCRYPT_CTX_ERROR || result == null) { |
|
const message = getStatus().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 |
|
*/ |
|
async kmsRequest(request, options) { |
|
const parsedUrl = request.endpoint.split(':'); |
|
const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT; |
|
const socketOptions = { |
|
host: parsedUrl[0], |
|
servername: parsedUrl[0], |
|
port, |
|
...(0, client_encryption_1.autoSelectSocketOptions)(this.options.socketOptions || {}) |
|
}; |
|
const message = request.message; |
|
const buffer = new utils_1.BufferPool(); |
|
let netSocket; |
|
let socket; |
|
function destroySockets() { |
|
for (const sock of [socket, netSocket]) { |
|
if (sock) { |
|
sock.destroy(); |
|
} |
|
} |
|
} |
|
function onerror(cause) { |
|
return new errors_1.MongoCryptError('KMS request failed', { cause }); |
|
} |
|
function onclose() { |
|
return new errors_1.MongoCryptError('KMS request closed'); |
|
} |
|
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) { |
|
throw error; |
|
} |
|
try { |
|
await this.setTlsOptions(providerTlsOptions, socketOptions); |
|
} |
|
catch (err) { |
|
throw onerror(err); |
|
} |
|
} |
|
} |
|
let abortListener; |
|
try { |
|
if (this.options.proxyOptions && this.options.proxyOptions.proxyHost) { |
|
netSocket = new net.Socket(); |
|
const { promise: willConnect, reject: rejectOnNetSocketError, resolve: resolveOnNetSocketConnect } = (0, utils_1.promiseWithResolvers)(); |
|
netSocket |
|
.once('error', err => rejectOnNetSocketError(onerror(err))) |
|
.once('close', () => rejectOnNetSocketError(onclose())) |
|
.once('connect', () => resolveOnNetSocketConnect()); |
|
const netSocketOptions = { |
|
...socketOptions, |
|
host: this.options.proxyOptions.proxyHost, |
|
port: this.options.proxyOptions.proxyPort || 1080 |
|
}; |
|
netSocket.connect(netSocketOptions); |
|
await willConnect; |
|
try { |
|
socks ??= loadSocks(); |
|
socketOptions.socket = (await socks.SocksClient.createConnection({ |
|
existing_socket: netSocket, |
|
command: 'connect', |
|
destination: { host: socketOptions.host, port: socketOptions.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) { |
|
throw onerror(err); |
|
} |
|
} |
|
socket = tls.connect(socketOptions, () => { |
|
socket.write(message); |
|
}); |
|
const { promise: willResolveKmsRequest, reject: rejectOnTlsSocketError, resolve } = (0, utils_1.promiseWithResolvers)(); |
|
abortListener = (0, utils_1.addAbortListener)(options?.signal, function () { |
|
destroySockets(); |
|
rejectOnTlsSocketError(this.reason); |
|
}); |
|
socket |
|
.once('error', err => rejectOnTlsSocketError(onerror(err))) |
|
.once('close', () => rejectOnTlsSocketError(onclose())) |
|
.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) { |
|
resolve(); |
|
} |
|
}); |
|
await (options?.timeoutContext?.csotEnabled() |
|
? Promise.all([ |
|
willResolveKmsRequest, |
|
timeout_1.Timeout.expires(options.timeoutContext?.remainingTimeMS) |
|
]) |
|
: willResolveKmsRequest); |
|
} |
|
catch (error) { |
|
if (error instanceof timeout_1.TimeoutError) |
|
throw new error_1.MongoOperationTimeoutError('KMS request timed out'); |
|
throw error; |
|
} |
|
finally { |
|
// There's no need for any more activity on this socket at this point. |
|
destroySockets(); |
|
abortListener?.[utils_1.kDispose](); |
|
} |
|
} |
|
*requests(context, options) { |
|
for (let request = context.nextKMSRequest(); request != null; request = context.nextKMSRequest()) { |
|
yield this.kmsRequest(request, options); |
|
} |
|
} |
|
/** |
|
* 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 |
|
*/ |
|
fetchCollectionInfo(client, ns, filter, options) { |
|
const { db } = utils_1.MongoDBCollectionNamespace.fromString(ns); |
|
const cursor = client.db(db).listCollections(filter, { |
|
promoteLongs: false, |
|
promoteValues: false, |
|
timeoutContext: options?.timeoutContext && new abstract_cursor_1.CursorTimeoutContext(options?.timeoutContext, Symbol()), |
|
signal: options?.signal, |
|
nameOnly: false |
|
}); |
|
return cursor; |
|
} |
|
/** |
|
* 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, options) { |
|
const { db } = utils_1.MongoDBCollectionNamespace.fromString(ns); |
|
const bsonOptions = { promoteLongs: false, promoteValues: false }; |
|
const rawCommand = (0, bson_1.deserialize)(command, bsonOptions); |
|
const commandOptions = { |
|
timeoutMS: undefined, |
|
signal: undefined |
|
}; |
|
if (options?.timeoutContext?.csotEnabled()) { |
|
commandOptions.timeoutMS = options.timeoutContext.remainingTimeMS; |
|
} |
|
if (options?.signal) { |
|
commandOptions.signal = options.signal; |
|
} |
|
const response = await client.db(db).command(rawCommand, { |
|
...bsonOptions, |
|
...commandOptions |
|
}); |
|
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, options) { |
|
const { db: dbName, collection: collectionName } = utils_1.MongoDBCollectionNamespace.fromString(keyVaultNamespace); |
|
const commandOptions = { |
|
timeoutContext: undefined, |
|
signal: undefined |
|
}; |
|
if (options?.timeoutContext != null) { |
|
commandOptions.timeoutContext = new abstract_cursor_1.CursorTimeoutContext(options.timeoutContext, Symbol()); |
|
} |
|
if (options?.signal != null) { |
|
commandOptions.signal = options.signal; |
|
} |
|
return client |
|
.db(dbName) |
|
.collection(collectionName, { readConcern: { level: 'majority' } }) |
|
.find((0, bson_1.deserialize)(filter), commandOptions) |
|
.toArray(); |
|
} |
|
} |
|
exports.StateMachine = StateMachine; |
|
//# sourceMappingURL=state_machine.js.map
|