diff --git a/.eslintrc.json b/.eslintrc.json index 6c738ef937..f7a60007a7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -277,7 +277,8 @@ "**/../lib/**", "mongodb-mock-server", "node:*", - "os" + "os", + "crypto" ], "paths": [ { @@ -335,4 +336,4 @@ } } ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index 41e2b842e7..255fe2fee5 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,16 @@ If you run into any unexpected compiler failures against our supported TypeScrip Additionally, our Typescript types are compatible with the ECMAScript standard for our minimum supported Node version. Currently, our Typescript targets es2023. +#### Running in Custom Runtimes + +We are working on removing NodeJS as a dependency of the driver, so that in the future it will be possible to use the drive in non-Node environments. +This work is currently in progress, and if you're curious, this is [our first runtime adapter commit](https://github.com/mongodb/node-mongodb-native/commit/d2ad07f20903d86334da81222a6df9717f76faaa). + +Some things to keep in mind if you are using a non-Node runtime: + +1. Users of Webpack/Vite may need to prevent `crypto` polyfill injection. +2. Auth mechanism `SCRAM-SHA-1` has a hard dependency on NodeJS and is not supported in FIPS mode. + ## Installation The recommended way to get started using the Node.js driver is by using the `npm` (Node Package Manager) to install the dependency in your project. diff --git a/src/cmap/auth/scram.ts b/src/cmap/auth/scram.ts index 63b22dc8bb..0635047fb7 100644 --- a/src/cmap/auth/scram.ts +++ b/src/cmap/auth/scram.ts @@ -1,5 +1,4 @@ import { saslprep } from '@mongodb-js/saslprep'; -import * as crypto from 'crypto'; import { Binary, ByteUtils, type Document } from '../../bson'; import { @@ -157,27 +156,27 @@ async function continueScramConversation( // Set up start of proof const withoutProof = `c=biws,r=${rnonce}`; - const saltedPassword = HI( + const saltedPassword = await HI( processedPassword, ByteUtils.fromBase64(salt), iterations, cryptoMethod ); - const clientKey = HMAC(cryptoMethod, saltedPassword, 'Client Key'); - const serverKey = HMAC(cryptoMethod, saltedPassword, 'Server Key'); - const storedKey = H(cryptoMethod, clientKey); + const clientKey = await HMAC(cryptoMethod, saltedPassword, 'Client Key'); + const serverKey = await HMAC(cryptoMethod, saltedPassword, 'Server Key'); + const storedKey = await H(cryptoMethod, clientKey); const authMessage = [ clientFirstMessageBare(username, nonce), payload.toString('utf8'), withoutProof ].join(','); - const clientSignature = HMAC(cryptoMethod, storedKey, authMessage); + const clientSignature = await HMAC(cryptoMethod, storedKey, authMessage); const clientProof = `p=${xor(clientKey, clientSignature)}`; const clientFinal = [withoutProof, clientProof].join(','); - const serverSignature = HMAC(cryptoMethod, serverKey, authMessage); + const serverSignature = await HMAC(cryptoMethod, serverKey, authMessage); const saslContinueCmd = { saslContinue: 1, conversationId: response.conversationId, @@ -229,19 +228,29 @@ function passwordDigest(username: string, password: string) { throw new MongoInvalidArgumentError('Password cannot be empty'); } - let md5: crypto.Hash; + let nodeCrypto; try { - md5 = crypto.createHash('md5'); + // TODO: NODE-7424 - remove dependency on 'crypto' for SCRAM-SHA-1 authentication + // eslint-disable-next-line @typescript-eslint/no-require-imports + nodeCrypto = require('crypto'); + } catch (e) { + throw new MongoRuntimeError('global crypto is required for SCRAM-SHA-1 authentication', { + cause: e + }); + } + + try { + const md5 = nodeCrypto.createHash('md5'); + md5.update(`${username}:mongo:${password}`, 'utf8'); + return md5.digest('hex'); } catch (err) { - if (crypto.getFips()) { + if (nodeCrypto.getFips()) { // This error is (slightly) more helpful than what comes from OpenSSL directly, e.g. // 'Error: error:060800C8:digital envelope routines:EVP_DigestInit_ex:disabled for FIPS' throw new Error('Auth mechanism SCRAM-SHA-1 is not supported in FIPS mode'); } throw err; } - md5.update(`${username}:mongo:${password}`, 'utf8'); - return md5.digest('hex'); } // XOR two buffers @@ -256,12 +265,28 @@ function xor(a: Uint8Array, b: Uint8Array) { return ByteUtils.toBase64(ByteUtils.fromNumberArray(res)); } -function H(method: CryptoMethod, text: Uint8Array): Uint8Array { - return crypto.createHash(method).update(text).digest(); +async function H(method: CryptoMethod, text: Uint8Array): Promise { + const buffer = await crypto.subtle.digest(method === 'sha256' ? 'SHA-256' : 'SHA-1', text); + return new Uint8Array(buffer); } -function HMAC(method: CryptoMethod, key: Uint8Array, text: Uint8Array | string): Uint8Array { - return crypto.createHmac(method, key).update(text).digest(); +async function HMAC( + method: CryptoMethod, + key: Uint8Array, + text: Uint8Array | string +): Promise { + const keyBuffer = ByteUtils.toLocalBufferType(key); + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBuffer, + { name: 'HMAC', hash: { name: method === 'sha256' ? 'SHA-256' : 'SHA-1' } }, + false, + ['sign', 'verify'] + ); + const textData: Uint8Array = typeof text === 'string' ? new TextEncoder().encode(text) : text; + const textBuffer = ByteUtils.toLocalBufferType(textData); + const signature = await crypto.subtle.sign('HMAC', cryptoKey, textBuffer); + return new Uint8Array(signature); } interface HICache { @@ -280,21 +305,32 @@ const hiLengthMap = { sha1: 20 }; -function HI(data: string, salt: Uint8Array, iterations: number, cryptoMethod: CryptoMethod) { +async function HI(data: string, salt: Uint8Array, iterations: number, cryptoMethod: CryptoMethod) { // omit the work if already generated const key = [data, ByteUtils.toBase64(salt), iterations].join('_'); if (_hiCache[key] != null) { return _hiCache[key]; } - // generate the salt - const saltedData = crypto.pbkdf2Sync( - data, - salt, - iterations, - hiLengthMap[cryptoMethod], - cryptoMethod + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(data), + { name: 'PBKDF2' }, + false, + ['deriveBits'] + ); + const params = { + name: 'PBKDF2', + salt: salt, + iterations: iterations, + hash: { name: cryptoMethod === 'sha256' ? 'SHA-256' : 'SHA-1' } + }; + const derivedBits = await crypto.subtle.deriveBits( + params, + keyMaterial, + hiLengthMap[cryptoMethod] * 8 ); + const saltedData = new Uint8Array(derivedBits); // cache a copy to speed up the next lookup, but prevent unbounded cache growth if (_hiCacheCount >= 200) { @@ -311,10 +347,6 @@ function compareDigest(lhs: Uint8Array, rhs: Uint8Array) { return false; } - if (typeof crypto.timingSafeEqual === 'function') { - return crypto.timingSafeEqual(lhs, rhs); - } - let result = 0; for (let i = 0; i < lhs.length; i++) { result |= lhs[i] ^ rhs[i]; diff --git a/src/utils.ts b/src/utils.ts index 5aadbf4e62..c85767242b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,3 @@ -import * as crypto from 'crypto'; import type { SrvRecord } from 'dns'; import { type EventEmitter } from 'events'; import { promises as fs } from 'fs'; @@ -306,7 +305,7 @@ export function* makeCounter(seed = 0): Generator { * @internal */ export function uuidV4(): Uint8Array { - const result = crypto.randomBytes(16); + const result = crypto.getRandomValues(new Uint8Array(16)); result[6] = (result[6] & 0x0f) | 0x40; result[8] = (result[8] & 0x3f) | 0x80; return result; @@ -1226,13 +1225,8 @@ export function squashError(_error: unknown) { return; } -export const randomBytes = (size: number) => { - return new Promise((resolve, reject) => { - crypto.randomBytes(size, (error: Error | null, buf: Uint8Array) => { - if (error) return reject(error); - resolve(buf); - }); - }); +export const randomBytes = (size: number): Promise => { + return Promise.resolve(crypto.getRandomValues(new Uint8Array(size))); }; /**