diff --git a/src/client-side-encryption/client_encryption.ts b/src/client-side-encryption/client_encryption.ts index 01c2cd3622..8e20b081ab 100644 --- a/src/client-side-encryption/client_encryption.ts +++ b/src/client-side-encryption/client_encryption.ts @@ -24,7 +24,7 @@ import { type MongoClient, type MongoClientOptions } from '../mongo_client'; import { type Filter, type WithId } from '../mongo_types'; import { type CreateCollectionOptions } from '../operations/create_collection'; import { type DeleteResult } from '../operations/delete'; -import { TimeoutContext } from '../timeout'; +import { type CSOTTimeoutContext, TimeoutContext } from '../timeout'; import { MongoDBCollectionNamespace, resolveTimeoutOptions } from '../utils'; import * as cryptoCallbacks from './crypto_callbacks'; import { @@ -220,7 +220,13 @@ export class ClientEncryption { socketOptions: autoSelectSocketOptions(this._client.s.options) }); - const dataKey = deserialize(await stateMachine.execute(this, context)) as DataKey; + const timeoutContext = + options?.timeoutContext ?? + TimeoutContext.create(resolveTimeoutOptions(this._client, { timeoutMS: this._timeoutMS })); + + const dataKey = deserialize( + await stateMachine.execute(this, context, timeoutContext) + ) as DataKey; const { db: dbName, collection: collectionName } = MongoDBCollectionNamespace.fromString( this._keyVaultNamespace @@ -229,7 +235,12 @@ export class ClientEncryption { const { insertedId } = await this._keyVaultClient .db(dbName) .collection(collectionName) - .insertOne(dataKey, { writeConcern: { w: 'majority' } }); + .insertOne(dataKey, { + writeConcern: { w: 'majority' }, + timeoutMS: timeoutContext?.csotEnabled() + ? timeoutContext?.getRemainingTimeMSOrThrow() + : undefined + }); return insertedId; } @@ -511,6 +522,7 @@ export class ClientEncryption { } } ]; + const value = await this._keyVaultClient .db(dbName) .collection(collectionName) @@ -555,16 +567,25 @@ export class ClientEncryption { } } = options; + const timeoutContext = + this._timeoutMS != null + ? TimeoutContext.create(resolveTimeoutOptions(this._client, { timeoutMS: this._timeoutMS })) + : undefined; + if (Array.isArray(encryptedFields.fields)) { const createDataKeyPromises = encryptedFields.fields.map(async field => field == null || typeof field !== 'object' || field.keyId != null ? field : { ...field, - keyId: await this.createDataKey(provider, { masterKey }) + keyId: await this.createDataKey(provider, { + masterKey, + // clone the timeoutContext + // in order to avoid sharing the same timeout for server selection and connection checkout across different concurrent operations + timeoutContext: timeoutContext?.csotEnabled() ? timeoutContext?.clone() : undefined + }) } ); - const createDataKeyResolutions = await Promise.allSettled(createDataKeyPromises); encryptedFields.fields = createDataKeyResolutions.map((resolution, index) => @@ -582,7 +603,10 @@ export class ClientEncryption { try { const collection = await db.createCollection(name, { ...createCollectionOptions, - encryptedFields + encryptedFields, + timeoutMS: timeoutContext?.csotEnabled() + ? timeoutContext?.getRemainingTimeMSOrThrow() + : undefined }); return { collection, encryptedFields }; } catch (cause) { @@ -667,7 +691,12 @@ export class ClientEncryption { socketOptions: autoSelectSocketOptions(this._client.s.options) }); - const { v } = deserialize(await stateMachine.execute(this, context)); + const timeoutContext = + this._timeoutMS != null + ? TimeoutContext.create(resolveTimeoutOptions(this._client, { timeoutMS: this._timeoutMS })) + : undefined; + + const { v } = deserialize(await stateMachine.execute(this, context, timeoutContext)); return v; } @@ -747,7 +776,11 @@ export class ClientEncryption { }); const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions); - const { v } = deserialize(await stateMachine.execute(this, context)); + const timeoutContext = + this._timeoutMS != null + ? TimeoutContext.create(resolveTimeoutOptions(this._client, { timeoutMS: this._timeoutMS })) + : undefined; + const { v } = deserialize(await stateMachine.execute(this, context, timeoutContext)); return v; } } @@ -833,7 +866,8 @@ export interface ClientEncryptionOptions { */ tlsOptions?: CSFLEKMSTlsOptions; - /** + /** @internal TODO(NODE-5688): make this public + * * The timeout setting to be used for all the operations on ClientEncryption. */ timeoutMS?: number; @@ -965,6 +999,9 @@ export interface ClientEncryptionCreateDataKeyProviderOptions { /** @experimental */ keyMaterial?: Buffer | Binary; + + /** @internal */ + timeoutContext?: CSOTTimeoutContext; } /** diff --git a/src/timeout.ts b/src/timeout.ts index 9041ce4b88..916e4594d2 100644 --- a/src/timeout.ts +++ b/src/timeout.ts @@ -323,6 +323,20 @@ export class CSOTTimeoutContext extends TimeoutContext { return remainingTimeMS; } + /** + * @internal + * This method is intended to be used in situations where concurrent operation are on the same deadline, but cannot share a single `TimeoutContext` instance. + * Returns a new instance of `CSOTTimeoutContext` constructed with identical options, but setting the `start` property to `this.start`. + */ + clone(): CSOTTimeoutContext { + const timeoutContext = new CSOTTimeoutContext({ + timeoutMS: this.timeoutMS, + serverSelectionTimeoutMS: this.serverSelectionTimeoutMS + }); + timeoutContext.start = this.start; + return timeoutContext; + } + override refreshed(): CSOTTimeoutContext { return new CSOTTimeoutContext(this); } diff --git a/test/integration/client-side-encryption/driver.test.ts b/test/integration/client-side-encryption/driver.test.ts index 937a197def..44a6b3cdee 100644 --- a/test/integration/client-side-encryption/driver.test.ts +++ b/test/integration/client-side-encryption/driver.test.ts @@ -12,6 +12,8 @@ import { Connection, CSOTTimeoutContext, type MongoClient, + MongoCryptCreateDataKeyError, + MongoCryptCreateEncryptedCollectionError, MongoOperationTimeoutError, StateMachine } from '../../mongodb'; @@ -1050,4 +1052,165 @@ describe('CSOT', function () { ); }); }); + + describe('Explicit Encryption', function () { + describe('#createEncryptedCollection', function () { + let client: MongoClient; + let clientEncryption: ClientEncryption; + let local_key; + const timeoutMS = 1000; + + const encryptedCollectionMetadata: MongoDBMetadataUI = { + requires: { + clientSideEncryption: true, + mongodb: '>=7.0.0', + topology: '!single' + } + }; + + beforeEach(async function () { + local_key = { local: EJSON.parse(process.env.CSFLE_KMS_PROVIDERS).local }; + client = this.configuration.newClient({ timeoutMS }); + await client.connect(); + await client.db('keyvault').createCollection('datakeys'); + clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'keyvault.datakeys', + keyVaultClient: client, + kmsProviders: local_key + }); + }); + + afterEach(async function () { + await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off' + } as FailPoint); + await client + .db('db') + .collection('newnew') + .drop() + .catch(() => null); + await client + .db('keyvault') + .collection('datakeys') + .drop() + .catch(() => null); + await client.close(); + }); + + async function runCreateEncryptedCollection() { + const createCollectionOptions = { + encryptedFields: { fields: [{ path: 'ssn', bsonType: 'string', keyId: null }] } + }; + + const db = client.db('db'); + + return await measureDuration(() => + clientEncryption + .createEncryptedCollection(db, 'newnew', { + provider: 'local', + createCollectionOptions, + masterKey: null + }) + .catch(err => err) + ); + } + + context( + 'when `createDataKey` hangs longer than timeoutMS and `createCollection` does not hang', + () => { + it( + '`createEncryptedCollection throws `MongoCryptCreateDataKeyError` due to a timeout error', + encryptedCollectionMetadata, + async function () { + await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['insert'], + blockConnection: true, + blockTimeMS: timeoutMS * 1.2 + } + } as FailPoint); + + const { duration, result: err } = await runCreateEncryptedCollection(); + expect(err).to.be.instanceOf(MongoCryptCreateDataKeyError); + expect(err.cause).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + } + ); + } + ); + + context( + 'when `createDataKey` does not hang and `createCollection` hangs longer than timeoutMS', + () => { + it( + '`createEncryptedCollection throws `MongoCryptCreateEncryptedCollectionError` due to a timeout error', + encryptedCollectionMetadata, + async function () { + await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['create'], + blockConnection: true, + blockTimeMS: timeoutMS * 1.2 + } + } as FailPoint); + + const { duration, result: err } = await runCreateEncryptedCollection(); + expect(err).to.be.instanceOf(MongoCryptCreateEncryptedCollectionError); + expect(err.cause).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + } + ); + } + ); + + context( + 'when `createDataKey` and `createCollection` cumulatively hang longer than timeoutMS', + () => { + it( + '`createEncryptedCollection throws `MongoCryptCreateEncryptedCollectionError` due to a timeout error', + encryptedCollectionMetadata, + async function () { + await client + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 2 + }, + data: { + failCommands: ['insert', 'create'], + blockConnection: true, + blockTimeMS: timeoutMS * 0.6 + } + } as FailPoint); + + const { duration, result: err } = await runCreateEncryptedCollection(); + expect(err).to.be.instanceOf(MongoCryptCreateEncryptedCollectionError); + expect(err.cause).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + } + ); + } + ); + }); + }); }); diff --git a/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts b/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts index 6094e2f8a6..644ac351fa 100644 --- a/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts +++ b/test/integration/client-side-operations-timeout/client_side_operations_timeout.prose.test.ts @@ -12,6 +12,8 @@ import { pipeline } from 'stream/promises'; import { type CommandStartedEvent } from '../../../mongodb'; import { + Binary, + ClientEncryption, type CommandSucceededEvent, GridFSBucket, MongoBulkWriteError, @@ -23,7 +25,7 @@ import { promiseWithResolvers, squashError } from '../../mongodb'; -import { type FailPoint, makeMultiBatchWrite } from '../../tools/utils'; +import { type FailPoint, makeMultiBatchWrite, measureDuration } from '../../tools/utils'; import { filterForCommands } from '../shared'; // TODO(NODE-5824): Implement CSOT prose tests @@ -163,8 +165,7 @@ describe('CSOT spec prose tests', function () { } ); - // TODO(NODE-6391): Add timeoutMS support to Explicit Encryption - context.skip('3. ClientEncryption', () => { + context('3. ClientEncryption', () => { /** * Each test under this category MUST only be run against server versions 4.4 and higher. In these tests, * `LOCAL_MASTERKEY` refers to the following base64: @@ -180,6 +181,49 @@ describe('CSOT spec prose tests', function () { * { local: { key: } } * ``` */ + let keyVaultClient: MongoClient; + let clientEncryption: ClientEncryption; + const LOCAL_MASTERKEY = Buffer.from( + 'Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk', + 'base64' + ); + + const clientEncryptionMetadata: MongoDBMetadataUI = { + requires: { + clientSideEncryption: true, + mongodb: '>=7.0.0', + topology: '!single' + } + } as const; + + const timeoutMS = 100; + + beforeEach(async function () { + await internalClient + .db('keyvault') + .collection('datakeys') + .drop() + .catch(() => null); + await internalClient.db('keyvault').collection('datakeys'); + keyVaultClient = this.configuration.newClient({}, { timeoutMS, monitorCommands: true }); + clientEncryption = new ClientEncryption(keyVaultClient, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_MASTERKEY } } + }); + }); + + afterEach(async function () { + await internalClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: 'off' + } as FailPoint); + await keyVaultClient.close(); + await internalClient.close(); + }); + context('createDataKey', () => { /** * 1. Using `internalClient`, set the following fail point: @@ -200,6 +244,34 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that an `insert` command was executed against to `keyvault.datakeys` as part of the `createDataKey` call. */ + + it('times out due to timeoutMS', clientEncryptionMetadata, async function () { + await internalClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['insert'], + blockConnection: true, + blockTimeMS: 150 + } + } as FailPoint); + const commandStarted: CommandStartedEvent[] = []; + keyVaultClient.on('commandStarted', ev => commandStarted.push(ev)); + + const { duration, result: err } = await measureDuration(() => + clientEncryption.createDataKey('local').catch(e => e) + ); + expect(err).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + const command = commandStarted[0].command; + expect(command).to.have.property('insert', 'datakeys'); + expect(command).to.have.property('$db', 'keyvault'); + }); }); context('encrypt', () => { @@ -224,6 +296,43 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that a `find` command was executed against the `keyvault.datakeys` collection as part of the `encrypt` call. */ + it('times out due to timeoutMS', clientEncryptionMetadata, async function () { + const datakeyId = await clientEncryption.createDataKey('local'); + expect(datakeyId).to.be.instanceOf(Binary); + expect(datakeyId.sub_type).to.equal(Binary.SUBTYPE_UUID); + + await internalClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['find'], + blockConnection: true, + blockTimeMS: 150 + } + } as FailPoint); + + const commandStarted: CommandStartedEvent[] = []; + keyVaultClient.on('commandStarted', ev => commandStarted.push(ev)); + + const { duration, result: err } = await measureDuration(() => + clientEncryption + .encrypt('hello', { + algorithm: `AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic`, + keyId: datakeyId + }) + .catch(e => e) + ); + expect(err).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + const command = commandStarted[0].command; + expect(command).to.have.property('find', 'datakeys'); + expect(command).to.have.property('$db', 'keyvault'); + }); }); context('decrypt', () => { @@ -251,6 +360,46 @@ describe('CSOT spec prose tests', function () { * - Expect this to fail with a timeout error. * 1. Verify that a `find` command was executed against the `keyvault.datakeys` collection as part of the `decrypt` call. */ + it('times out due to timeoutMS', clientEncryptionMetadata, async function () { + const datakeyId = await clientEncryption.createDataKey('local'); + expect(datakeyId).to.be.instanceOf(Binary); + expect(datakeyId.sub_type).to.equal(Binary.SUBTYPE_UUID); + + // pre-compute 'hello' encryption, otherwise the data key is cached sometimes and find in stateMachine.execute never runs + const encrypted = Binary.createFromBase64( + 'Af6ie/LRP0uoisAZthHPUs0CKzTBFIkJr8kxmOk1pV1C/6K54otT8QvNJgNTNG2CNpThhfdXaObuOMMReNlTgwapqPYCb/HJRQ1Nfma6uA3cTg==', + 6 + ); + expect(encrypted).to.be.instanceOf(Binary); + expect(encrypted.sub_type).to.equal(Binary.SUBTYPE_ENCRYPTED); + + await internalClient + .db() + .admin() + .command({ + configureFailPoint: 'failCommand', + mode: { + times: 1 + }, + data: { + failCommands: ['find'], + blockConnection: true, + blockTimeMS: 150 + } + } as FailPoint); + + const commandStarted: CommandStartedEvent[] = []; + keyVaultClient.on('commandStarted', ev => commandStarted.push(ev)); + + const { duration, result: err } = await measureDuration(() => + clientEncryption.decrypt(encrypted).catch(e => e) + ); + expect(err).to.be.instanceOf(MongoOperationTimeoutError); + expect(duration).to.be.within(timeoutMS - 100, timeoutMS + 100); + const command = commandStarted[0].command; + expect(command).to.have.property('find', 'datakeys'); + expect(command).to.have.property('$db', 'keyvault'); + }); }); }); diff --git a/test/unit/client-side-encryption/client_encryption.test.ts b/test/unit/client-side-encryption/client_encryption.test.ts index 8489138742..aeb1ac9bee 100644 --- a/test/unit/client-side-encryption/client_encryption.test.ts +++ b/test/unit/client-side-encryption/client_encryption.test.ts @@ -14,7 +14,7 @@ import { } from '../../../src/client-side-encryption/errors'; // eslint-disable-next-line @typescript-eslint/no-restricted-imports import { StateMachine } from '../../../src/client-side-encryption/state_machine'; -import { Binary, BSON, deserialize } from '../../mongodb'; +import { Binary, BSON, deserialize, MongoClient } from '../../mongodb'; const { EJSON } = BSON; @@ -102,6 +102,49 @@ describe('ClientEncryption', function () { expect(ClientEncryption.libmongocryptVersion).to.be.a('string'); }); + describe('constructor', () => { + describe('_timeoutMS', () => { + const LOCAL_MASTERKEY = Buffer.from( + 'Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk', + 'base64' + ); + context('when timeoutMS is provided in ClientEncryptionOptions and client', function () { + it('sets clientEncryption._timeoutMS to ClientEncryptionOptions.timeoutMS value', function () { + const client = new MongoClient('mongodb://a/', { timeoutMS: 100 }); + const clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_MASTERKEY } }, + timeoutMS: 500 + }); + expect(clientEncryption._timeoutMS).to.equal(500); + }); + }); + + context('when timeoutMS is only provided in ClientEncryptionOptions', function () { + it('sets clientEncryption._timeoutMS to ClientEncryptionOptions.timeoutMS value', function () { + const client = new MongoClient('mongodb://a/'); + const clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_MASTERKEY } }, + timeoutMS: 500 + }); + expect(clientEncryption._timeoutMS).to.equal(500); + }); + }); + + context('when timeoutMS is only provided in client', function () { + it('sets clientEncryption._timeoutMS to client.timeoutMS value', function () { + const client = new MongoClient('mongodb://a/', { timeoutMS: 100 }); + const clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_MASTERKEY } } + }); + expect(clientEncryption._timeoutMS).to.equal(100); + }); + }); + }); + }); + describe('createEncryptedCollection()', () => { let clientEncryption; const client = new MockClient(); @@ -160,7 +203,10 @@ describe('ClientEncryption', function () { expect(createDataKeySpy.callCount).to.equal(0); const options = createCollectionSpy.getCall(0).args[1]; - expect(options).to.deep.equal({ encryptedFields: { fields: 'not an array' } }); + expect(options).to.deep.equal({ + encryptedFields: { fields: 'not an array' }, + timeoutMS: undefined + }); }); }); @@ -178,7 +224,8 @@ describe('ClientEncryption', function () { expect(createDataKeyStub.callCount).to.equal(1); const options = createCollectionSpy.getCall(0).args[1]; expect(options).to.deep.equal({ - encryptedFields: { fields: ['not an array', { keyId: keyId }, { keyId: {} }] } + encryptedFields: { fields: ['not an array', { keyId: keyId }, { keyId: {} }] }, + timeoutMS: undefined }); }); }); @@ -194,7 +241,10 @@ describe('ClientEncryption', function () { masterKey }); expect(result).to.have.property('collection'); - expect(createDataKey).to.have.been.calledOnceWithExactly('aws', { masterKey }); + expect(createDataKey).to.have.been.calledOnceWithExactly('aws', { + masterKey, + timeoutContext: undefined + }); }); context('when createDataKey rejects', () => {