Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,8 @@
"**/../lib/**",
"mongodb-mock-server",
"node:*",
"os"
"os",
"crypto"
],
"paths": [
{
Expand Down Expand Up @@ -335,4 +336,4 @@
}
}
]
}
}
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 60 additions & 28 deletions src/cmap/auth/scram.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { saslprep } from '@mongodb-js/saslprep';
import * as crypto from 'crypto';

import { Binary, ByteUtils, type Document } from '../../bson';
import {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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<Uint8Array> {
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<Uint8Array> {
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 {
Expand All @@ -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) {
Expand All @@ -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];
Expand Down
12 changes: 3 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -306,7 +305,7 @@ export function* makeCounter(seed = 0): Generator<number> {
* @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;
Expand Down Expand Up @@ -1226,13 +1225,8 @@ export function squashError(_error: unknown) {
return;
}

export const randomBytes = (size: number) => {
return new Promise<Uint8Array>((resolve, reject) => {
crypto.randomBytes(size, (error: Error | null, buf: Uint8Array) => {
if (error) return reject(error);
resolve(buf);
});
});
export const randomBytes = (size: number): Promise<Uint8Array> => {
return Promise.resolve(crypto.getRandomValues(new Uint8Array(size)));
};

/**
Expand Down