From 4b9a72306f8a3cc714b13e46f26cf47aa406db7e Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Thu, 5 Feb 2026 13:56:29 -0800 Subject: [PATCH 1/5] feat(NODE-7379): Refactor Crypto to Web Crypto API --- .eslintrc.json | 5 ++- README.md | 10 +++++ src/cmap/auth/scram.ts | 88 ++++++++++++++++++++++++++++-------------- src/utils.ts | 12 ++---- 4 files changed, 76 insertions(+), 39 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 6c738ef9379..f7a60007a75 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 41e2b842e77..255fe2fee54 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 63b22dc8bb4..0635047fb7d 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 5aadbf4e624..c85767242b6 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))); }; /** From 962d66c2a1328e562d88eafccd820843da94ac8d Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Fri, 6 Feb 2026 15:00:51 -0800 Subject: [PATCH 2/5] ensure that compareDigest uses a time-constant algorithm, in this case Double HMAC Verification --- src/cmap/auth/scram.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/cmap/auth/scram.ts b/src/cmap/auth/scram.ts index 0635047fb7d..ad560a0b11c 100644 --- a/src/cmap/auth/scram.ts +++ b/src/cmap/auth/scram.ts @@ -186,7 +186,7 @@ async function continueScramConversation( const r = await connection.command(ns(`${db}.$cmd`), saslContinueCmd, undefined); const parsedResponse = parsePayload(r.payload); - if (!compareDigest(ByteUtils.fromBase64(parsedResponse.v), serverSignature)) { + if (!(await compareDigest(ByteUtils.fromBase64(parsedResponse.v), serverSignature))) { throw new MongoRuntimeError('Server returned an invalid signature'); } @@ -342,17 +342,25 @@ async function HI(data: string, salt: Uint8Array, iterations: number, cryptoMeth return saltedData; } -function compareDigest(lhs: Uint8Array, rhs: Uint8Array) { +async function compareDigest(lhs: Uint8Array, rhs: Uint8Array) { if (lhs.length !== rhs.length) { return false; } - let result = 0; - for (let i = 0; i < lhs.length; i++) { - result |= lhs[i] ^ rhs[i]; - } + // Compare values using a time-constant algorithm to prevent against timing attacks + // The approach is called "Double HMAC Verification". The basic idea is: + // 1. Generate a random key + // 2. HMAC the random key with both values + // 3. Compare the HMACs using an equality check + + const randomKey = crypto.getRandomValues(new Uint8Array(32)); + const lhsHMAC = await HMAC('sha256', randomKey, lhs); + const rhsHMAC = await HMAC('sha256', randomKey, rhs); + const lhsHex = ByteUtils.toHex(lhsHMAC); + const rhsHex = ByteUtils.toHex(rhsHMAC); + const areEqual = lhsHex === rhsHex; - return result === 0; + return areEqual; } export class ScramSHA1 extends ScramSHA { From 652d9914b8d19cfad58d42eccecc3a1617ad2fb1 Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Tue, 10 Feb 2026 12:11:58 -0800 Subject: [PATCH 3/5] restore xor algorithm for compareDigest --- src/cmap/auth/scram.ts | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/cmap/auth/scram.ts b/src/cmap/auth/scram.ts index ad560a0b11c..0635047fb7d 100644 --- a/src/cmap/auth/scram.ts +++ b/src/cmap/auth/scram.ts @@ -186,7 +186,7 @@ async function continueScramConversation( const r = await connection.command(ns(`${db}.$cmd`), saslContinueCmd, undefined); const parsedResponse = parsePayload(r.payload); - if (!(await compareDigest(ByteUtils.fromBase64(parsedResponse.v), serverSignature))) { + if (!compareDigest(ByteUtils.fromBase64(parsedResponse.v), serverSignature)) { throw new MongoRuntimeError('Server returned an invalid signature'); } @@ -342,25 +342,17 @@ async function HI(data: string, salt: Uint8Array, iterations: number, cryptoMeth return saltedData; } -async function compareDigest(lhs: Uint8Array, rhs: Uint8Array) { +function compareDigest(lhs: Uint8Array, rhs: Uint8Array) { if (lhs.length !== rhs.length) { return false; } - // Compare values using a time-constant algorithm to prevent against timing attacks - // The approach is called "Double HMAC Verification". The basic idea is: - // 1. Generate a random key - // 2. HMAC the random key with both values - // 3. Compare the HMACs using an equality check - - const randomKey = crypto.getRandomValues(new Uint8Array(32)); - const lhsHMAC = await HMAC('sha256', randomKey, lhs); - const rhsHMAC = await HMAC('sha256', randomKey, rhs); - const lhsHex = ByteUtils.toHex(lhsHMAC); - const rhsHex = ByteUtils.toHex(rhsHMAC); - const areEqual = lhsHex === rhsHex; + let result = 0; + for (let i = 0; i < lhs.length; i++) { + result |= lhs[i] ^ rhs[i]; + } - return areEqual; + return result === 0; } export class ScramSHA1 extends ScramSHA { From 8433095436123dceea6f4cbfcd3be0448568ab7f Mon Sep 17 00:00:00 2001 From: Pavel Safronov Date: Fri, 13 Mar 2026 09:29:59 -0700 Subject: [PATCH 4/5] pr feedback: updated error language and clarified SCRAM-SHA-1 info in docs --- README.md | 5 +++-- src/cmap/auth/scram.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 255fe2fee54..55dc947b0b7 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,14 @@ Additionally, our Typescript types are compatible with the ECMAScript standard f #### 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. +We are working on removing NodeJS as a dependency of the driver, so that in the future it will be possible to use the driver 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. +2. Auth mechanism `SCRAM-SHA-1` has a hard dependency on NodeJS. +3. Auth mechanism `SCRAM-SHA-1` is not supported in FIPS mode. ## Installation diff --git a/src/cmap/auth/scram.ts b/src/cmap/auth/scram.ts index 0635047fb7d..842edd46e0c 100644 --- a/src/cmap/auth/scram.ts +++ b/src/cmap/auth/scram.ts @@ -234,9 +234,12 @@ function passwordDigest(username: string, password: string) { // 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 - }); + throw new MongoRuntimeError( + 'Node.js crypto module is required for SCRAM-SHA-1 authentication', + { + cause: e + } + ); } try { From d0a94da340e0108abdf75a56087221630904a5b4 Mon Sep 17 00:00:00 2001 From: Sergey Zelenov Date: Mon, 16 Mar 2026 16:14:41 +0100 Subject: [PATCH 5/5] Apply suggestions from code review Co-authored-by: Anna Henningsen --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55dc947b0b7..bf10fe6dac0 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ Additionally, our Typescript types are compatible with the ECMAScript standard f #### 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 driver in non-Node environments. +We are working on removing Node.js as a dependency of the driver, so that in the future it will be possible to use the driver 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. +2. Auth mechanism `SCRAM-SHA-1` has a hard dependency on Node.js. 3. Auth mechanism `SCRAM-SHA-1` is not supported in FIPS mode. ## Installation