diff --git a/.github/docker/Dockerfile.glibc b/.github/docker/Dockerfile.glibc index c7f3833..e0a89d3 100644 --- a/.github/docker/Dockerfile.glibc +++ b/.github/docker/Dockerfile.glibc @@ -1,9 +1,11 @@ -FROM ubuntu:bionic AS build +ARG UBUNTU_VERSION=bionic +FROM ubuntu:${UBUNTU_VERSION} AS build +ARG NODE_VERSION=16.20.1 # Possible values: s390x, arm64, x64 ARG NODE_ARCH -ADD https://nodejs.org/dist/v16.20.1/node-v16.20.1-linux-${NODE_ARCH}.tar.gz / -RUN mkdir -p /nodejs && tar -xzf /node-v16.20.1-linux-${NODE_ARCH}.tar.gz --strip-components=1 -C /nodejs +ADD https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz / +RUN mkdir -p /nodejs && tar -xzf /node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz --strip-components=1 -C /nodejs ENV PATH=$PATH:/nodejs/bin WORKDIR /mongodb-client-encryption @@ -11,7 +13,10 @@ COPY . . RUN apt-get -qq update && apt-get -qq install -y python3 build-essential && ldd --version -RUN npm run install:libmongocrypt && npm run test +RUN npm run install:libmongocrypt + +ARG RUN_TEST +RUN [ -n "$RUN_TEST" ] && npm run test || echo 'skipping testing!' FROM scratch diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 89b16d0..c2e37bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: {} workflow_call: {} -name: Build and Test +name: Build permissions: contents: write @@ -24,10 +24,6 @@ jobs: run: node .github/scripts/libmongocrypt.mjs ${{ runner.os == 'Windows' && '--build' || '' }} shell: bash - - name: Test ${{ matrix.os }} - shell: bash - run: npm run test - - id: upload name: Upload prebuild uses: actions/upload-artifact@v4 @@ -57,7 +53,12 @@ jobs: - name: Run Buildx run: | docker buildx create --name builder --bootstrap --use - docker buildx build --platform linux/${{ matrix.linux_arch }} --build-arg NODE_ARCH=${{ matrix.linux_arch == 'amd64' && 'x64' || matrix.linux_arch }} --output type=local,dest=./prebuilds,platform-split=false -f ./.github/docker/Dockerfile.glibc . + docker buildx build \ + --platform linux/${{ matrix.linux_arch }} \ + --build-arg="NODE_ARCH=${{ matrix.linux_arch == 'amd64' && 'x64' || matrix.linux_arch }}" \ + --output type=local,dest=./prebuilds,platform-split=false \ + -f ./.github/docker/Dockerfile.glibc \ + . - id: upload name: Upload prebuild diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dd07514 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,71 @@ +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: {} + +name: Test + +jobs: + host_tests: + strategy: + matrix: + os: [macos-latest, windows-2019] + node: [16.x, 18.x, 20.x, 22.x] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Build with Node.js ${{ matrix.node }} on ${{ matrix.os }} + run: node .github/scripts/libmongocrypt.mjs ${{ runner.os == 'Windows' && '--build' || '' }} + shell: bash + + - name: Test ${{ matrix.os }} + shell: bash + run: npm run test + + container_tests: + runs-on: ubuntu-latest + strategy: + matrix: + linux_arch: [s390x, arm64, amd64] + node: [16.x, 18.x, 20.x, 22.x] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + + - name: Get Full Node.js Version + id: get_nodejs_version + shell: bash + run: | + echo "version=$(node --print 'process.version.slice(1)')" >> "$GITHUB_OUTPUT" + echo "ubuntu_version=$(node --print '(+process.version.slice(1).split(`.`).at(0)) > 16 ? `noble` : `bionic`')" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run Buildx + run: | + docker buildx create --name builder --bootstrap --use + docker buildx build \ + --platform linux/${{ matrix.linux_arch }} \ + --build-arg="NODE_ARCH=${{ matrix.linux_arch == 'amd64' && 'x64' || matrix.linux_arch }}" \ + --build-arg="NODE_VERSION=${{ steps.get_nodejs_version.outputs.version }}" \ + --build-arg="UBUNTU_VERSION=${{ steps.get_nodejs_version.outputs.ubuntu_version }}" \ + --build-arg="RUN_TEST=true" \ + --output type=local,dest=./prebuilds,platform-split=false \ + -f ./.github/docker/Dockerfile.glibc \ + . diff --git a/addon/mongocrypt.cc b/addon/mongocrypt.cc index 70046ca..421bb93 100644 --- a/addon/mongocrypt.cc +++ b/addon/mongocrypt.cc @@ -120,6 +120,7 @@ Function MongoCrypt::Init(Napi::Env env) { InstanceMethod("makeDataKeyContext", &MongoCrypt::MakeDataKeyContext), InstanceMethod("makeRewrapManyDataKeyContext", &MongoCrypt::MakeRewrapManyDataKeyContext), InstanceAccessor("status", &MongoCrypt::Status, nullptr), + InstanceAccessor("cryptoHooksProvider", &MongoCrypt::CryptoHooksProvider, nullptr), InstanceAccessor( "cryptSharedLibVersionInfo", &MongoCrypt::CryptSharedLibVersionInfo, nullptr), StaticValue("libmongocryptVersion", String::New(env, mongocrypt_version(nullptr)))}); @@ -201,7 +202,7 @@ static bool aes_256_generic_hook(MongoCrypt* mongoCrypt, return true; } -bool MongoCrypt::setupCryptoHooks() { +std::unique_ptr MongoCrypt::createJSCryptoHooks() { auto aes_256_cbc_encrypt = [](void* ctx, mongocrypt_binary_t* key, mongocrypt_binary_t* iv, @@ -398,26 +399,47 @@ bool MongoCrypt::setupCryptoHooks() { return true; }; + return std::make_unique(CryptoHooks{"js", + aes_256_cbc_encrypt, + aes_256_cbc_decrypt, + random, + hmac_sha_512, + hmac_sha_256, + sha_256, + aes_256_ctr_encrypt, + aes_256_ctr_decrypt, + nullptr, + sign_rsa_sha256, + this}); +} + +bool MongoCrypt::installCryptoHooks() { + const auto& hooks = *_crypto_hooks; if (!mongocrypt_setopt_crypto_hooks(_mongo_crypt.get(), - aes_256_cbc_encrypt, - aes_256_cbc_decrypt, - random, - hmac_sha_512, - hmac_sha_256, - sha_256, - this)) { + hooks.aes_256_cbc_encrypt, + hooks.aes_256_cbc_decrypt, + hooks.random, + hooks.hmac_sha_512, + hooks.hmac_sha_256, + hooks.sha_256, + hooks.ctx)) { return false; } // Added after `mongocrypt_setopt_crypto_hooks`, they should be treated as the same during // configuration if (!mongocrypt_setopt_crypto_hook_sign_rsaes_pkcs1_v1_5( - _mongo_crypt.get(), sign_rsa_sha256, this)) { + _mongo_crypt.get(), hooks.sign_rsa_sha256, this)) { return false; } if (!mongocrypt_setopt_aes_256_ctr( - _mongo_crypt.get(), aes_256_ctr_encrypt, aes_256_ctr_decrypt, this)) { + _mongo_crypt.get(), hooks.aes_256_ctr_encrypt, hooks.aes_256_ctr_decrypt, hooks.ctx)) { + return false; + } + + if (hooks.aes_256_ecb_encrypt && + !mongocrypt_setopt_aes_256_ecb(_mongo_crypt.get(), hooks.aes_256_ecb_encrypt, hooks.ctx)) { return false; } @@ -472,7 +494,10 @@ MongoCrypt::MongoCrypt(const CallbackInfo& info) } } - if (options.Has("cryptoCallbacks")) { + if (!_crypto_hooks) { + _crypto_hooks = opensslcrypto::createOpenSSLCryptoHooks(); + } + if (!_crypto_hooks && options.Has("cryptoCallbacks")) { Object cryptoCallbacks = options.Get("cryptoCallbacks").ToObject(); SetCallback("aes256CbcEncryptHook", cryptoCallbacks["aes256CbcEncryptHook"]); @@ -484,10 +509,10 @@ MongoCrypt::MongoCrypt(const CallbackInfo& info) SetCallback("hmacSha256Hook", cryptoCallbacks["hmacSha256Hook"]); SetCallback("sha256Hook", cryptoCallbacks["sha256Hook"]); SetCallback("signRsaSha256Hook", cryptoCallbacks["signRsaSha256Hook"]); - - if (!setupCryptoHooks()) { - throw Error::New(Env(), "unable to configure crypto hooks"); - } + _crypto_hooks = createJSCryptoHooks(); + } + if (_crypto_hooks && !installCryptoHooks()) { + throw Error::New(Env(), "unable to configure crypto hooks"); } if (options.Has("cryptSharedLibSearchPaths")) { @@ -535,6 +560,12 @@ Value MongoCrypt::CryptSharedLibVersionInfo(const CallbackInfo& info) { return ret; } +Value MongoCrypt::CryptoHooksProvider(const CallbackInfo& info) { + if (!_crypto_hooks) + return Env().Null(); + return String::New(Env(), _crypto_hooks->id); +} + Value MongoCrypt::Status(const CallbackInfo& info) { std::unique_ptr status(mongocrypt_status_new()); mongocrypt_status(_mongo_crypt.get(), status.get()); diff --git a/addon/mongocrypt.h b/addon/mongocrypt.h index 1c8f2cd..61d6950 100644 --- a/addon/mongocrypt.h +++ b/addon/mongocrypt.h @@ -19,6 +19,21 @@ extern "C" { namespace node_mongocrypt { +struct CryptoHooks { + const char* id; + mongocrypt_crypto_fn aes_256_cbc_encrypt; + mongocrypt_crypto_fn aes_256_cbc_decrypt; + mongocrypt_random_fn random; + mongocrypt_hmac_fn hmac_sha_512; + mongocrypt_hmac_fn hmac_sha_256; + mongocrypt_hash_fn sha_256; + mongocrypt_crypto_fn aes_256_ctr_encrypt; + mongocrypt_crypto_fn aes_256_ctr_decrypt; + mongocrypt_crypto_fn aes_256_ecb_encrypt; + mongocrypt_hmac_fn sign_rsa_sha256; + void* ctx; +}; + struct MongoCryptBinaryDeleter { void operator()(mongocrypt_binary_t* binary) { mongocrypt_binary_destroy(binary); @@ -37,6 +52,10 @@ struct MongoCryptContextDeleter { } }; +namespace opensslcrypto { +std::unique_ptr createOpenSSLCryptoHooks(); +} + class MongoCrypt : public Napi::ObjectWrap { public: static Napi::Function Init(Napi::Env env); @@ -51,6 +70,7 @@ class MongoCrypt : public Napi::ObjectWrap { Napi::Value Status(const Napi::CallbackInfo& info); Napi::Value CryptSharedLibVersionInfo(const Napi::CallbackInfo& info); + Napi::Value CryptoHooksProvider(const Napi::CallbackInfo& info); private: friend class Napi::ObjectWrap; @@ -58,13 +78,15 @@ class MongoCrypt : public Napi::ObjectWrap { void SetCallback(const char* name, Napi::Value fn); explicit MongoCrypt(const Napi::CallbackInfo& info); - bool setupCryptoHooks(); + std::unique_ptr createJSCryptoHooks(); + bool installCryptoHooks(); static void logHandler(mongocrypt_log_level_t level, const char* message, uint32_t message_len, void* ctx); + std::unique_ptr _crypto_hooks; std::unique_ptr _mongo_crypt; }; diff --git a/addon/openssl-crypto.cc b/addon/openssl-crypto.cc new file mode 100644 index 0000000..23cde32 --- /dev/null +++ b/addon/openssl-crypto.cc @@ -0,0 +1,458 @@ +// Adapted from +// https://github.com/mongodb/libmongocrypt/blob/18cb9e4e900c45c0b9b71fd34e159f8cb29fe1de/src/crypto/libcrypto.c +// and +// https://github.com/mongodb/libmongocrypt/blob/18cb9e4e900c45c0b9b71fd34e159f8cb29fe1de/kms-message/src/kms_crypto_libcrypto.c +// This file provides native crypto hooks for OpenSSL 3 (the default since Node.js 18), +// allowing us to skip expensive round-trips between JS and C++. + +#include "mongocrypt.h" + +// Electron does not expose OpenSSL, so we cannot use OpenSSL +// functions directly if we're building against Electron: +// https://github.com/electron/electron/issues/13176 +#ifndef MONGOCRYPT_AVOID_OPENSSL_CRYPTO +#include +#include +#include +#include +#include + +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +#undef ASSERT +#undef STRINGIFY +#define STRINGIFY(x) #x +#define ASSERT(x) \ + do { \ + if (!(x)) { \ + throw std::runtime_error("Assertion failed: " #x " (at " __FILE__ \ + ":" STRINGIFY(__LINE__) ")"); \ + } \ + } while (0) + +// Helpers to easily access OpenSSL symbols in a type-safe way. +#ifdef MONGO_CLIENT_ENCRYPTION_STATIC_OPENSSL +#define S_Unchecked(x) (x) +#define S(x) S_Unchecked(x) +#else +#define S_Unchecked(x) \ + ([&]() { \ + static void* const sym = node_mongocrypt::opensslcrypto::opensslsym(#x); \ + return reinterpret_cast(sym); \ + })() +#define S(x) \ + ([&]() { \ + static auto* sym = S_Unchecked(x); \ + if (!sym) \ + throw new std::runtime_error("Unable to look up OpenSSL symbol: " #x); \ + return sym; \ + })() +#endif + +// While we target OpenSSL 3 here, we still need to support building on +// Node.js versions that support OpenSSL 1.1. These three functions +// changed in definition between these versions, so we explicitly spell out +// their OpenSSL 3 prototypes. +extern "C" { +#undef EVP_CIPHER_get_iv_length +int EVP_CIPHER_get_iv_length(const EVP_CIPHER* cipher); +#undef EVP_CIPHER_get_key_length +int EVP_CIPHER_get_key_length(const EVP_CIPHER* cipher); +#undef EVP_DigestSignUpdate +int EVP_DigestSignUpdate(EVP_MD_CTX* ctx, const void* data, size_t dsize); +} + +namespace node_mongocrypt { +namespace opensslcrypto { + +void* opensslsym(const char* symname); + +// Helper to use RAII together with C cleanup functions. +template +struct CleanupImpl { + T fn; + bool active; + + CleanupImpl(T&& fn) : fn(std::move(fn)), active(true) {} + ~CleanupImpl() { + if (active) + fn(); + } + + CleanupImpl(const CleanupImpl&) = delete; + CleanupImpl& operator=(const CleanupImpl&) = delete; + CleanupImpl(CleanupImpl&& other) : fn(std::move(other.fn)), active(true) { + other.active = false; + } +}; +template +CleanupImpl Cleanup(T&& fn) { + return CleanupImpl{std::move(fn)}; +} + +static bool set_status_from_openssl(mongocrypt_status_t* status, const char* base_error) { + std::string error_message = base_error; + error_message += ": "; + error_message += S(ERR_error_string)(S(ERR_get_error)(), nullptr); + mongocrypt_status_set(status, + MONGOCRYPT_STATUS_ERROR_CLIENT, + 1, + error_message.c_str(), + error_message.length() + 1); + return false; +} + +/* encrypt_with_cipher encrypts @in with the OpenSSL cipher specified by + * @Cipher. + * @key is the input key. @iv is the input IV. + * @out is the output ciphertext. @out must be allocated by the caller with + * enough room for the ciphertext. + * @bytes_written is the number of bytes that were written to @out. + * Returns false and sets @status on error. @status is required. */ +template +bool encrypt_with_cipher(void* unused_ctx, + mongocrypt_binary_t* key, + mongocrypt_binary_t* iv, + mongocrypt_binary_t* in, + mongocrypt_binary_t* out, + uint32_t* bytes_written, + mongocrypt_status_t* status) { + int intermediate_bytes_written = 0; + + const EVP_CIPHER* cipher = Cipher::Get(); + EVP_CIPHER_CTX* ctx = S(EVP_CIPHER_CTX_new)(); + auto cleanup_ctx = Cleanup([&]() { S(EVP_CIPHER_CTX_free)(ctx); }); + + ASSERT(key); + ASSERT(in); + ASSERT(out); + ASSERT(ctx); + ASSERT(cipher); + ASSERT(nullptr == iv || static_cast(S(EVP_CIPHER_get_iv_length)(cipher)) == iv->len); + ASSERT(static_cast(S(EVP_CIPHER_get_key_length)(cipher)) == key->len); + ASSERT(in->len <= INT_MAX); + + if (!S(EVP_EncryptInit_ex)(ctx, + cipher, + nullptr /* engine */, + static_cast(key->data), + nullptr == iv ? nullptr : static_cast(iv->data))) { + return set_status_from_openssl(status, "error in EVP_EncryptInit_ex"); + } + + /* Disable the default OpenSSL padding. */ + S(EVP_CIPHER_CTX_set_padding)(ctx, 0); + + *bytes_written = 0; + if (!S(EVP_EncryptUpdate)(ctx, + static_cast(out->data), + &intermediate_bytes_written, + static_cast(in->data), + static_cast(in->len))) { + return set_status_from_openssl(status, "error in EVP_EncryptUpdate"); + } + + ASSERT(intermediate_bytes_written >= 0 && + static_cast(intermediate_bytes_written) <= UINT32_MAX); + /* intermediate_bytes_written cannot be negative, so int -> uint32_t is OK */ + *bytes_written = static_cast(intermediate_bytes_written); + + if (!S(EVP_EncryptFinal_ex)( + ctx, static_cast(out->data), &intermediate_bytes_written)) { + return set_status_from_openssl(status, "error in EVP_EncryptFinal_ex"); + } + + ASSERT(UINT32_MAX - *bytes_written >= static_cast(intermediate_bytes_written)); + *bytes_written += static_cast(intermediate_bytes_written); + + return true; +} + +/* decrypt_with_cipher decrypts @in with the OpenSSL cipher specified by + * @Cipher. + * @key is the input key. @iv is the input IV. + * @out is the output plaintext. @out must be allocated by the caller with + * enough room for the plaintext. + * @bytes_written is the number of bytes that were written to @out. + * Returns false and sets @status on error. @status is required. */ +template +bool decrypt_with_cipher(void* unused_ctx, + mongocrypt_binary_t* key, + mongocrypt_binary_t* iv, + mongocrypt_binary_t* in, + mongocrypt_binary_t* out, + uint32_t* bytes_written, + mongocrypt_status_t* status) { + int intermediate_bytes_written = 0; + + const EVP_CIPHER* cipher = Cipher::Get(); + EVP_CIPHER_CTX* ctx = S(EVP_CIPHER_CTX_new)(); + auto cleanup_ctx = Cleanup([&]() { S(EVP_CIPHER_CTX_free)(ctx); }); + ASSERT(ctx); + + ASSERT(cipher); + ASSERT(iv); + ASSERT(key); + ASSERT(in); + ASSERT(out); + ASSERT(static_cast(S(EVP_CIPHER_get_iv_length)(cipher)) == iv->len); + ASSERT(static_cast(S(EVP_CIPHER_get_key_length)(cipher)) == key->len); + ASSERT(in->len <= INT_MAX); + + if (!S(EVP_DecryptInit_ex)(ctx, + cipher, + nullptr /* engine */, + static_cast(key->data), + static_cast(iv->data))) { + return set_status_from_openssl(status, "error in EVP_DecryptInit_ex"); + } + + /* Disable padding. */ + S(EVP_CIPHER_CTX_set_padding)(ctx, 0); + + *bytes_written = 0; + + if (!S(EVP_DecryptUpdate)(ctx, + static_cast(out->data), + &intermediate_bytes_written, + static_cast(in->data), + static_cast(in->len))) { + return set_status_from_openssl(status, "error in EVP_DecryptUpdate"); + } + + ASSERT(intermediate_bytes_written >= 0 && + static_cast(intermediate_bytes_written) <= UINT32_MAX); + /* intermediate_bytes_written cannot be negative, so int -> uint32_t is OK */ + *bytes_written = static_cast(intermediate_bytes_written); + + if (!S(EVP_DecryptFinal_ex)( + ctx, static_cast(out->data), &intermediate_bytes_written)) { + return set_status_from_openssl(status, "error in EVP_DecryptFinal_ex"); + } + + ASSERT(UINT32_MAX - *bytes_written >= static_cast(intermediate_bytes_written)); + *bytes_written += static_cast(intermediate_bytes_written); + return true; +} + +/* hmac_with_hash computes an HMAC of @in with the OpenSSL hash specified by + * @Hash. + * @key is the input key. + * @out is the output. @out must be allocated by the caller with + * the exact length for the output. E.g. for HMAC 256, @out->len must be 32. + * Returns false and sets @status on error. @status is required. */ +template +bool hmac_with_hash(void* unused_ctx, + mongocrypt_binary_t* key, + mongocrypt_binary_t* in, + mongocrypt_binary_t* out, + mongocrypt_status_t* status) { + const EVP_MD* hash = Hash::Get(); + ASSERT(hash); + ASSERT(key); + ASSERT(in); + ASSERT(out); + + if (!S(HMAC)(hash, + key->data, + static_cast(key->len), + static_cast(in->data), + in->len, + static_cast(out->data), + nullptr /* unused out len */)) { + return set_status_from_openssl(status, "error initializing HMAC"); + } + return true; +} + +bool random_fn(void* unused_ctx, + mongocrypt_binary_t* out, + uint32_t count, + mongocrypt_status_t* status) { + ASSERT(out); + ASSERT(count <= INT_MAX); + + int ret = S(RAND_bytes)(static_cast(out->data), static_cast(count)); + /* From man page: "RAND_bytes() and RAND_priv_bytes() return 1 on success, -1 + * if not supported by the current RAND method, or 0 on other failure. The + * error code can be obtained by ERR_get_error(3)" */ + if (ret == -1) { + return set_status_from_openssl(status, "secure random IV not supported"); + } else if (ret == 0) { + return set_status_from_openssl(status, "failed to generate random"); + } + return true; +} + +template +bool compute_hash(void* unused_ctx, + mongocrypt_binary_t* in, + mongocrypt_binary_t* out, + mongocrypt_status_t* status) { + const EVP_MD* hash = Hash::Get(); + ASSERT(hash); + + EVP_MD_CTX* digest_ctxp = S(EVP_MD_CTX_new)(); + auto cleanup_ctx = Cleanup([&]() { S(EVP_MD_CTX_free)(digest_ctxp); }); + + if (!S(EVP_DigestInit_ex)(digest_ctxp, hash, nullptr)) { + return set_status_from_openssl(status, "error in EVP_DigestInit_ex"); + } + + if (!S(EVP_DigestUpdate)(digest_ctxp, static_cast(in->data), in->len)) { + return set_status_from_openssl(status, "error in EVP_DigestUpdate"); + } + + if (!S(EVP_DigestFinal_ex)(digest_ctxp, static_cast(out->data), nullptr)) { + return set_status_from_openssl(status, "error in EVP_DigestFinal_ex"); + } + + return true; +} + +template +bool sign_rsa(void* unused_ctx, + mongocrypt_binary_t* key, + mongocrypt_binary_t* in, + mongocrypt_binary_t* out, + mongocrypt_status_t* status) { + const EVP_MD* hash = Hash::Get(); + + ASSERT(hash); + ASSERT(key); + ASSERT(in); + ASSERT(out); + ASSERT(status); + + EVP_PKEY* pkey = nullptr; + size_t signature_out_len = 256; + + EVP_MD_CTX* ctx = S(EVP_MD_CTX_new)(); + auto cleanup_ctx = Cleanup([&]() { S(EVP_MD_CTX_free)(ctx); }); + ASSERT(key->len <= LONG_MAX); + const unsigned char* key_data = static_cast(key->data); + pkey = S(d2i_PrivateKey)(EVP_PKEY_RSA, nullptr, &key_data, static_cast(key->len)); + auto cleanup_pkey = Cleanup([&]() { S(EVP_PKEY_free)(pkey); }); + if (!pkey) { + return set_status_from_openssl(status, "error in d2i_PrivateKey"); + } + + if (!S(EVP_DigestSignInit)(ctx, nullptr, hash, nullptr /* engine */, pkey)) { + return set_status_from_openssl(status, "error in EVP_DigestSignInit"); + } + + if (!S(EVP_DigestSignUpdate)(ctx, static_cast(in->data), in->len)) { + return set_status_from_openssl(status, "error in EVP_DigestSignUpdate"); + } + + if (!S(EVP_DigestSignFinal)(ctx, static_cast(out->data), &signature_out_len)) { + return set_status_from_openssl(status, "error in EVP_DigestSignFinal"); + } + + return true; +} + +void* opensslsym(const char* name) { + struct OwnProcessDylib { + bool initialized = false; +#ifdef _WIN32 + HMODULE lib; + + OwnProcessDylib() { + lib = GetModuleHandle(nullptr); + } + + void* sym(const char* name) { + return reinterpret_cast(reinterpret_cast(GetProcAddress(lib, name))); + } +#else + void* lib = nullptr; + + OwnProcessDylib() { + lib = dlopen(nullptr, RTLD_NOW); + } + + ~OwnProcessDylib() { + dlclose(lib); + } + + void* sym(const char* name) { + return reinterpret_cast(dlsym(lib, name)); + } +#endif + }; + static OwnProcessDylib dl; + if (!dl.lib) { + throw new std::runtime_error("Could not open process handle"); + } + + return dl.sym(name); +} + +std::unique_ptr createOpenSSLCryptoHooks() { + auto version_num_fn = S_Unchecked(OpenSSL_version_num); + if (!version_num_fn) + return {}; + unsigned long openssl_version = version_num_fn(); // 0xMNN00PP0L + // Check that OpenSSL version is in [3.0.0, 4.0.0) + if (openssl_version < 0x30000000L || openssl_version >= 0x40000000L) + return {}; + + struct AES256CBC { + static const EVP_CIPHER* Get() { + return S(EVP_aes_256_cbc)(); + } + }; + struct AES256ECB { + static const EVP_CIPHER* Get() { + return S(EVP_aes_256_ecb)(); + } + }; + struct AES256CTR { + static const EVP_CIPHER* Get() { + return S(EVP_aes_256_ctr)(); + } + }; + struct SHA512 { + static const EVP_MD* Get() { + return S(EVP_sha512)(); + } + }; + struct SHA256 { + static const EVP_MD* Get() { + return S(EVP_sha256)(); + } + }; + + return std::make_unique(CryptoHooks{"native_openssl", + encrypt_with_cipher, + decrypt_with_cipher, + random_fn, + hmac_with_hash, + hmac_with_hash, + compute_hash, + encrypt_with_cipher, + decrypt_with_cipher, + encrypt_with_cipher, + sign_rsa, + nullptr}); +} + +} // namespace opensslcrypto +} // namespace node_mongocrypt + +#else // MONGOCRYPT_AVOID_OPENSSL_CRYPTO +namespace node_mongocrypt { +namespace opensslcrypto { +std::unique_ptr createOpenSSLCryptoHooks() { + return {}; +} +} // namespace opensslcrypto +} // namespace node_mongocrypt +#endif // MONGOCRYPT_AVOID_OPENSSL_CRYPTO diff --git a/binding.gyp b/binding.gyp index 9fc0370..d51d66e 100644 --- a/binding.gyp +++ b/binding.gyp @@ -8,9 +8,12 @@ 'variables': { 'ARCH': '<(host_arch)', 'libmongocrypt_link_type%': 'static', + 'mongocrypt_avoid_openssl_crypto%': 'false', + 'built_with_electron%': 0 }, 'sources': [ - 'addon/mongocrypt.cc' + 'addon/mongocrypt.cc', + 'addon/openssl-crypto.cc' ], 'xcode_settings': { 'GCC_ENABLE_CPP_EXCEPTIONS': 'YES', @@ -24,6 +27,7 @@ 'VCCLCompilerTool': { 'ExceptionHandling': 1 }, }, 'conditions': [ + ['mongocrypt_avoid_openssl_crypto=="true" or built_with_electron==1', { 'defines': ['MONGOCRYPT_AVOID_OPENSSL_CRYPTO'] }], ['OS=="mac"', { 'cflags+': ['-fvisibility=hidden'] }], ['_type!="static_library" and ARCH=="arm64"', { 'xcode_settings': { diff --git a/package-lock.json b/package-lock.json index 9ae05e4..4d62aa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,12 @@ }, "devDependencies": { "@types/bindings": "^1.5.5", - "@types/chai": "^4.3.14", + "@types/chai": "^4.3.16", "@types/mocha": "^10.0.6", "@types/node": "^20.12.7", + "@types/semver": "^7.5.8", + "@types/sinon": "^17.0.3", + "@types/sinon-chai": "^3.2.12", "@typescript-eslint/eslint-plugin": "^7.7.0", "bson": "^6.7.0", "chai": "^4.4.1", @@ -31,6 +34,7 @@ "node-gyp": "^10.1.0", "prebuild": "^13.0.0", "prettier": "^3.2.5", + "semver": "^7.6.2", "sinon": "^17.0.1", "sinon-chai": "^3.7.0", "source-map-support": "^0.5.21", @@ -625,9 +629,9 @@ } }, "node_modules/@types/chai": { - "version": "4.3.14", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.14.tgz", - "integrity": "sha512-Wj71sXE4Q4AkGdG9Tvq1u/fquNz9EdG4LIJMwVVII7ashjD/8cf8fyIfJAjRr6YcsXnSE8cOGQPq1gqeR8z+3w==", + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.16.tgz", + "integrity": "sha512-PatH4iOdyh3MyWtmHVFXLWCCIhUbopaltqddG9BzB+gMIzee2MJrvd+jouii9Z3wzQJruGWAm7WOMjgfG8hQlQ==", "dev": true }, "node_modules/@types/json-schema": { @@ -669,6 +673,31 @@ "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", "dev": true }, + "node_modules/@types/sinon": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", + "integrity": "sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw==", + "dev": true, + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinon-chai": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.12.tgz", + "integrity": "sha512-9y0Gflk3b0+NhQZ/oxGtaAJDvRywCa5sIyaVnounqLvmf93yBF4EgIRspePtkMs3Tr844nCclYMlcCNmLCvjuQ==", + "dev": true, + "dependencies": { + "@types/chai": "*", + "@types/sinon": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", + "integrity": "sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.0.tgz", @@ -6613,12 +6642,9 @@ "dev": true }, "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -6626,17 +6652,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/serialize-javascript": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", @@ -7767,7 +7782,8 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true }, "node_modules/yargs": { "version": "16.2.0", diff --git a/package.json b/package.json index 702605d..153372f 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,12 @@ }, "devDependencies": { "@types/bindings": "^1.5.5", - "@types/chai": "^4.3.14", + "@types/chai": "^4.3.16", "@types/mocha": "^10.0.6", "@types/node": "^20.12.7", + "@types/semver": "^7.5.8", + "@types/sinon": "^17.0.3", + "@types/sinon-chai": "^3.2.12", "@typescript-eslint/eslint-plugin": "^7.7.0", "bson": "^6.7.0", "chai": "^4.4.1", @@ -57,6 +60,7 @@ "node-gyp": "^10.1.0", "prebuild": "^13.0.0", "prettier": "^3.2.5", + "semver": "^7.6.2", "sinon": "^17.0.1", "sinon-chai": "^3.7.0", "source-map-support": "^0.5.21", diff --git a/src/crypto_callbacks.ts b/src/crypto_callbacks.ts new file mode 100644 index 0000000..1160038 --- /dev/null +++ b/src/crypto_callbacks.ts @@ -0,0 +1,106 @@ +import * as crypto from 'crypto'; + +type AES256Callback = (key: Buffer, iv: Buffer, input: Buffer, output: Buffer) => number | Error; + +function makeAES256Hook( + method: 'createCipheriv' | 'createDecipheriv', + mode: 'aes-256-cbc' | 'aes-256-ctr' +): AES256Callback { + return function (key: Buffer, iv: Buffer, input: Buffer, output: Buffer): number | Error { + let result; + + try { + const cipher = crypto[method](mode, key, iv); + cipher.setAutoPadding(false); + result = cipher.update(input); + const final = cipher.final(); + if (final.length > 0) { + result = Buffer.concat([result, final]); + } + } catch (e) { + return e; + } + + result.copy(output); + return result.length; + }; +} + +function randomHook(buffer: Buffer, count: number): number | Error { + try { + crypto.randomFillSync(buffer, 0, count); + } catch (e) { + return e; + } + return count; +} + +function sha256Hook(input: Buffer, output: Buffer): number | Error { + let result; + try { + result = crypto.createHash('sha256').update(input).digest(); + } catch (e) { + return e; + } + + result.copy(output); + return result.length; +} + +type HMACHook = (key: Buffer, input: Buffer, output: Buffer) => number | Error; + +export function makeHmacHook(algorithm: 'sha512' | 'sha256'): HMACHook { + return (key: Buffer, input: Buffer, output: Buffer): number | Error => { + let result; + try { + result = crypto.createHmac(algorithm, key).update(input).digest(); + } catch (e) { + return e; + } + + result.copy(output); + return result.length; + }; +} + +function signRsaSha256Hook(key: Buffer, input: Buffer, output: Buffer): number | Error { + let result; + try { + const signer = crypto.createSign('sha256WithRSAEncryption'); + const privateKey = Buffer.from( + `-----BEGIN PRIVATE KEY-----\n${key.toString('base64')}\n-----END PRIVATE KEY-----\n` + ); + + result = signer.update(input).end().sign(privateKey); + } catch (e) { + return e; + } + + result.copy(output); + return result.length; +} + +const aes256CbcEncryptHook = makeAES256Hook('createCipheriv', 'aes-256-cbc'); +const aes256CbcDecryptHook = makeAES256Hook('createDecipheriv', 'aes-256-cbc'); +const aes256CtrEncryptHook = makeAES256Hook('createCipheriv', 'aes-256-ctr'); +const aes256CtrDecryptHook = makeAES256Hook('createDecipheriv', 'aes-256-ctr'); +const hmacSha512Hook = makeHmacHook('sha512'); +const hmacSha256Hook = makeHmacHook('sha256'); + +/** + * @public + * + * A JS implementation of crypto callbacks used for encryption. + * These are only necessary on versions of Node.js that do not bundle OpenSSL 3.x + */ +export const cryptoCallbacks = { + randomHook, + sha256Hook, + signRsaSha256Hook, + aes256CbcEncryptHook, + aes256CbcDecryptHook, + aes256CtrEncryptHook, + aes256CtrDecryptHook, + hmacSha512Hook, + hmacSha256Hook +}; diff --git a/src/index.ts b/src/index.ts index 7355840..85ab3d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,18 @@ +import { cryptoCallbacks } from './crypto_callbacks'; +export { cryptoCallbacks }; + import bindings = require('bindings'); +const mc: MongoCryptBindings = bindings('mongocrypt'); -const mc = bindings('mongocrypt'); +/** + * The value returned by the native bindings + * reference the `Init(Env env, Object exports)` function in the c++ + */ +type MongoCryptBindings = { + MongoCrypt: MongoCryptConstructor; + MongoCryptContextCtor: MongoCryptContextCtor; + MongoCryptKMSRequestCtor: MongoCryptKMSRequest; +}; export interface MongoCryptKMSRequest { addResponse(response: Uint8Array): void; @@ -30,17 +42,19 @@ export interface MongoCryptContext { readonly state: number; } +type MongoCryptConstructorOptions = { + kmsProviders?: Uint8Array; + schemaMap?: Uint8Array; + encryptedFieldsMap?: Uint8Array; + logger?: unknown; + cryptoCallbacks?: Record; + cryptSharedLibSearchPaths?: string[]; + cryptSharedLibPath?: string; + bypassQueryAnalysis?: boolean; +}; + export interface MongoCryptConstructor { - new (options: { - kmsProviders?: Uint8Array; - schemaMap?: Uint8Array; - encryptedFieldsMap?: Uint8Array; - logger?: unknown; - cryptoCallbacks?: Record; - cryptSharedLibSearchPaths?: string[]; - cryptSharedLibPath?: string; - bypassQueryAnalysis?: boolean; - }): MongoCrypt; + new (options: MongoCryptConstructorOptions): MongoCrypt; libmongocryptVersion: string; } @@ -80,6 +94,7 @@ export interface MongoCrypt { version: bigint; versionStr: string; } | null; + readonly cryptoHooksProvider: 'js' | 'native_openssl' | null; } export type ExplicitEncryptionContextOptions = NonNullable< @@ -88,7 +103,16 @@ export type ExplicitEncryptionContextOptions = NonNullable< export type DataKeyContextOptions = NonNullable[1]>; export type MongoCryptOptions = NonNullable[0]>; -export const MongoCrypt: MongoCryptConstructor = mc.MongoCrypt; +export const MongoCrypt: MongoCryptConstructor = class MongoCrypt extends mc.MongoCrypt { + constructor(options: MongoCryptConstructorOptions) { + // Pass in JS cryptoCallbacks implementation by default. + // If the Node.js openssl version is supported this will be ignored. + super( + // @ts-expect-error: intentionally passing in an argument that will throw to preserve existing behavior + options == null || typeof options !== 'object' ? undefined : { cryptoCallbacks, ...options } + ); + } +}; /** exported for testing only. */ interface MongoCryptContextCtor { diff --git a/test/benchmarks/bench.mjs b/test/benchmarks/bench.mjs index 7062a5d..37cc459 100644 --- a/test/benchmarks/bench.mjs +++ b/test/benchmarks/bench.mjs @@ -114,19 +114,20 @@ function main() { ].join('\n'); console.log(systemInfo()); + const mongoCryptOptions = { kmsProviders: BSON.serialize(kmsProviders) }; + if (!BENCH_WITH_NATIVE_CRYPTO) mongoCryptOptions.cryptoCallbacks = cryptoCallbacks; + if (cryptSharedLibPath) mongoCryptOptions.cryptSharedLibPath = cryptSharedLibPath; + + const mongoCrypt = new MongoCrypt(mongoCryptOptions); + console.log( `BenchmarkRunner is using ` + `libmongocryptVersion=${MongoCrypt.libmongocryptVersion}, ` + + `cryptoHooks=${mongoCrypt.cryptoHooksProvider}, ` + `warmupSecs=${warmupSecs}, ` + `testInSecs=${testInSecs}` ); - const mongoCryptOptions = { kmsProviders: BSON.serialize(kmsProviders) }; - if (!BENCH_WITH_NATIVE_CRYPTO) mongoCryptOptions.cryptoCallbacks = cryptoCallbacks; - if (cryptSharedLibPath) mongoCryptOptions.cryptSharedLibPath = cryptSharedLibPath; - - const mongoCrypt = new MongoCrypt(mongoCryptOptions); - const encrypted = createEncryptedDocument(mongoCrypt); const toDecrypt = BSON.serialize(encrypted); diff --git a/test/crypto.test.ts b/test/crypto.test.ts new file mode 100644 index 0000000..628dd33 --- /dev/null +++ b/test/crypto.test.ts @@ -0,0 +1,150 @@ +import * as semver from 'semver'; +import * as process from 'node:process'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as sinon from 'sinon'; +import { EJSON, BSON, Binary } from 'bson'; +import { MongoCrypt, MongoCryptConstructor, cryptoCallbacks } from '../src'; +import { expect } from 'chai'; + +const NEED_MONGO_KEYS = 3; +const READY = 5; +const ERROR = 0; + +const LOCAL_KEY = new Uint8Array([ + 0x9d, 0x94, 0x4b, 0x0d, 0x93, 0xd0, 0xc5, 0x44, 0xa5, 0x72, 0xfd, 0x32, 0x1b, 0x94, 0x30, 0x90, + 0x23, 0x35, 0x73, 0x7c, 0xf0, 0xf6, 0xc2, 0xf4, 0xda, 0x23, 0x56, 0xe7, 0x8f, 0x04, 0xcc, 0xfa, + 0xde, 0x75, 0xb4, 0x51, 0x87, 0xf3, 0x8b, 0x97, 0xd7, 0x4b, 0x44, 0x3b, 0xac, 0x39, 0xa2, 0xc6, + 0x4d, 0x91, 0x00, 0x3e, 0xd1, 0xfa, 0x4a, 0x30, 0xc1, 0xd2, 0xc6, 0x5e, 0xfb, 0xac, 0x41, 0xf2, + 0x48, 0x13, 0x3c, 0x9b, 0x50, 0xfc, 0xa7, 0x24, 0x7a, 0x2e, 0x02, 0x63, 0xa3, 0xc6, 0x16, 0x25, + 0x51, 0x50, 0x78, 0x3e, 0x0f, 0xd8, 0x6e, 0x84, 0xa6, 0xec, 0x8d, 0x2d, 0x24, 0x47, 0xe5, 0xaf +]); + +const kmsProviders = { local: { key: LOCAL_KEY } }; +const algorithm = 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'; +const keyDocument = EJSON.parse( + fs.readFileSync(path.join(__dirname, 'benchmarks', 'keyDocument.json'), 'utf8'), + { + relaxed: false + } +); + +function createEncryptedDocument(mongoCrypt: MongoCrypt) { + const { _id: keyId } = keyDocument; + + const encrypted = { myEncryptedKey: undefined }; + + const v = 'mySecretValue'; + + const ctx = mongoCrypt.makeExplicitEncryptionContext(BSON.serialize({ v }), { + keyId: keyId.buffer, + algorithm + }); + + const getState = () => ctx.state; + + if (getState() === NEED_MONGO_KEYS) { + ctx.addMongoOperationResponse(BSON.serialize(keyDocument)); + ctx.finishMongoOperation(); + } + + if (getState() !== READY) throw new Error(`not ready: [${ctx.state}] ${ctx.status.message}`); + const result = ctx.finalize(); + if (getState() === ERROR) throw new Error(`error: [${ctx.state}] ${ctx.status.message}`); + const { v: encryptedValue } = BSON.deserialize(result); + encrypted.myEncryptedKey = encryptedValue; + + return encrypted; +} + +describe('Crypto hooks', () => { + describe('when openssl 3 available', () => { + beforeEach('check ssl version', function () { + const openssl = semver.coerce(process.versions.openssl); + + if (!(semver.gte(openssl, '3.0.0') && semver.lt(openssl, '4.0.0'))) { + this.skip(); + } + }); + + it('reports crypto hook provider as `native_openssl`', () => { + const mongoCryptOptions: ConstructorParameters[0] = { + kmsProviders: BSON.serialize(kmsProviders), + cryptoCallbacks + }; + + const mongoCrypt = new MongoCrypt(mongoCryptOptions); + + expect(mongoCrypt).to.have.property('cryptoHooksProvider', 'native_openssl'); + }); + + it('should use native crypto hooks', async () => { + const spiedCallbacks = Object.fromEntries( + Object.entries(cryptoCallbacks).map(([name, hook]) => [name, sinon.spy(hook)]) + ); + + const mongoCryptOptions: ConstructorParameters[0] = { + kmsProviders: BSON.serialize(kmsProviders), + cryptoCallbacks: spiedCallbacks + }; + + const mongoCrypt = new MongoCrypt(mongoCryptOptions); + + const encryptedDoc = createEncryptedDocument(mongoCrypt); + + expect(encryptedDoc).to.have.property('myEncryptedKey').that.is.instanceOf(Binary); + + for (const [name, hook] of Object.entries(spiedCallbacks)) + expect(hook, name).to.not.have.been.called; + }); + }); + + describe('when openssl 3 is unavailable', () => { + beforeEach('check ssl version', function () { + const openssl = semver.coerce(process.versions.openssl); + + if (semver.gte(openssl, '3.0.0') && semver.lt(openssl, '4.0.0')) { + this.skip(); + } + }); + + it('reports crypto hook provider as `js`', () => { + const mongoCryptOptions: ConstructorParameters[0] = { + kmsProviders: BSON.serialize(kmsProviders), + cryptoCallbacks + }; + + const mongoCrypt = new MongoCrypt(mongoCryptOptions); + + expect(mongoCrypt).to.have.property('cryptoHooksProvider', 'js'); + }); + + it('should use js crypto hooks', async () => { + const spiedCallbacks = Object.fromEntries( + Object.entries(cryptoCallbacks).map(([name, hook]) => [name, sinon.spy(hook)]) + ); + + const mongoCryptOptions: ConstructorParameters[0] = { + kmsProviders: BSON.serialize(kmsProviders), + cryptoCallbacks: spiedCallbacks + }; + + const mongoCrypt = new MongoCrypt(mongoCryptOptions); + + const encryptedDoc = createEncryptedDocument(mongoCrypt); + + expect(encryptedDoc).to.have.property('myEncryptedKey').that.is.instanceOf(Binary); + + expect(spiedCallbacks).to.have.property('aes256CbcEncryptHook').to.have.callCount(1); + expect(spiedCallbacks).to.have.property('aes256CbcDecryptHook').to.have.callCount(1); + expect(spiedCallbacks).to.have.property('hmacSha512Hook').to.have.callCount(3); + + expect(spiedCallbacks).to.have.property('randomHook').to.not.have.been.called; + expect(spiedCallbacks).to.have.property('sha256Hook').to.not.have.been.called; + expect(spiedCallbacks).to.have.property('signRsaSha256Hook').to.not.have.been.called; + expect(spiedCallbacks).to.have.property('aes256CtrEncryptHook').to.not.have.been.called; + expect(spiedCallbacks).to.have.property('aes256CtrDecryptHook').to.not.have.been.called; + expect(spiedCallbacks).to.have.property('hmacSha256Hook').to.not.have.been.called; + }); + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts index a125a0c..26846f3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -2,19 +2,23 @@ import { expect } from 'chai'; import * as bindings from '../src/index'; describe('index.ts', () => { - it('only has one export', () => { - expect(Object.keys(bindings).length).to.equal(2); + it('only has three exports', () => { + expect(Object.keys(bindings).length).to.equal(3); }); it('exports a class MongoCrypt', () => { - expect(bindings).to.have.property('MongoCrypt'); + expect(bindings).to.have.property('MongoCrypt').that.is.a('function'); }); it('exposes MongoCryptContextCtor', () => { - expect(bindings).to.have.property('MongoCryptContextCtor'); + expect(bindings).to.have.property('MongoCryptContextCtor').that.is.a('function'); }); it('exposes MongoCryptKMSRequestCtor', () => { - expect(bindings).not.to.have.property('MongoCryptKMSRequestCtor'); + expect(bindings).not.to.have.property('MongoCryptKMSRequestCtor').that.is.a('function'); + }); + + it('exports a cryptoCallbacks object', () => { + expect(bindings).to.have.property('cryptoCallbacks').that.is.an('object'); }); }); diff --git a/test/release.test.ts b/test/release.test.ts index 5b71e83..f82c473 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -8,6 +8,7 @@ const packFile = `mongodb-client-encryption-${pkg.version}.tgz`; const REQUIRED_FILES = [ 'package/LICENSE', + 'package/addon/openssl-crypto.cc', 'package/addon/mongocrypt.cc', 'package/binding.gyp', 'package/addon/mongocrypt.h', @@ -18,7 +19,13 @@ const REQUIRED_FILES = [ 'package/HISTORY.md', 'package/README.md', 'package/lib/index.d.ts', - 'package/src/index.ts' + 'package/src/index.ts', + + 'package/src/crypto_callbacks.ts', + 'package/lib/crypto_callbacks.d.ts', + 'package/lib/crypto_callbacks.d.ts.map', + 'package/lib/crypto_callbacks.js', + 'package/lib/crypto_callbacks.js.map' ]; describe(`Release ${packFile}`, function () {