From dffa62db931d919bd53a4f01ad47d3d18c73c0d7 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 29 Jun 2026 14:54:03 +0200 Subject: [PATCH 1/2] crypto: support OpenSSL STORE private keys Signed-off-by: Filip Skokan --- .github/workflows/build-shared.yml | 9 +- .github/workflows/test-shared.yml | 1 + deps/ncrypto/ncrypto.cc | 175 +++++- deps/ncrypto/ncrypto.h | 14 + doc/api/cli.md | 20 + doc/api/crypto.md | 32 +- doc/api/permissions.md | 8 +- doc/api/process.md | 2 + doc/node-config-schema.json | 8 + doc/node.1 | 11 + lib/internal/crypto/keys.js | 56 +- lib/internal/process/permission.js | 1 + lib/internal/process/pre_execution.js | 1 + node.gyp | 2 + src/crypto/crypto_dsa.cc | 53 +- src/crypto/crypto_ec.cc | 39 +- src/crypto/crypto_keys.cc | 116 +++- src/crypto/crypto_rsa.cc | 77 ++- src/crypto/crypto_sig.cc | 121 +++- src/env.cc | 4 + src/node_options.cc | 7 + src/node_options.h | 1 + src/permission/crypto_store_permission.cc | 31 + src/permission/crypto_store_permission.h | 34 + src/permission/permission.cc | 8 + src/permission/permission.h | 1 + src/permission/permission_base.h | 6 +- test/parallel/test-crypto-key-store-pkcs11.js | 585 ++++++++++++++++++ test/parallel/test-crypto-key-store.js | 94 +++ test/parallel/test-permission-crypto-store.js | 55 ++ test/parallel/test-permission-has.js | 1 + .../parallel/test-permission-warning-flags.js | 1 + typings/internalBinding/crypto.d.ts | 11 +- 33 files changed, 1500 insertions(+), 85 deletions(-) create mode 100644 src/permission/crypto_store_permission.cc create mode 100644 src/permission/crypto_store_permission.h create mode 100644 test/parallel/test-crypto-key-store-pkcs11.js create mode 100644 test/parallel/test-crypto-key-store.js create mode 100644 test/parallel/test-permission-crypto-store.js diff --git a/.github/workflows/build-shared.yml b/.github/workflows/build-shared.yml index 61441571427eff..98d6dff6c417e5 100644 --- a/.github/workflows/build-shared.yml +++ b/.github/workflows/build-shared.yml @@ -22,6 +22,11 @@ on: required: false type: string default: '' + pkcs11-store-test: + description: Whether to enable the PKCS#11-backed crypto STORE test + required: false + type: boolean + default: false secrets: CACHIX_AUTH_TOKEN: description: Cachix auth token for nodejs.cachix.org. @@ -74,10 +79,12 @@ jobs: - name: Build Node.js and run tests shell: bash + env: + NODE_TEST_PKCS11_NIX: ${{ inputs.pkcs11-store-test && '1' || '0' }} run: | nix-shell \ -I "nixpkgs=$TAR_DIR/tools/nix/pkgs.nix" \ - --pure --keep TAR_DIR --keep FLAKY_TESTS \ + --pure --keep TAR_DIR --keep FLAKY_TESTS --keep NODE_TEST_PKCS11_NIX \ --keep SCCACHE_GHA_ENABLED --keep ACTIONS_CACHE_SERVICE_V2 --keep ACTIONS_RESULTS_URL --keep ACTIONS_RUNTIME_TOKEN \ --arg loadJSBuiltinsDynamically false \ --arg ccache "${NIX_SCCACHE:-null}" \ diff --git a/.github/workflows/test-shared.yml b/.github/workflows/test-shared.yml index ef22c29f478c86..09bc2776159638 100644 --- a/.github/workflows/test-shared.yml +++ b/.github/workflows/test-shared.yml @@ -245,6 +245,7 @@ jobs: with: runner: ubuntu-24.04-arm v8-nar: ${{ needs.build-aarch64-linux-v8.outputs.local-cache && 'libv8-aarch64-linux.nar' }} + pkcs11-store-test: ${{ matrix.openssl.attr == 'openssl_3_5' }} # Override just the `openssl` attr of the default shared-lib set with # the matrix-selected nixpkgs attribute (e.g. `openssl_3_6`). All # other shared libs (brotli, cares, libuv, …) keep their defaults. diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 81af3ded563777..56d850ffe80a52 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -20,6 +20,7 @@ #include #include #include +#include #if OPENSSL_WITH_ARGON2 #include #endif @@ -74,6 +75,7 @@ namespace { using BignumCtxPointer = DeleteFnPtr; using BignumGenCallbackPointer = DeleteFnPtr; using NetscapeSPKIPointer = DeleteFnPtr; +using X509PubKeyPointer = DeleteFnPtr; static constexpr int kX509NameFlagsRFC2253WithinUtf8JSON = XN_FLAG_RFC2253 & ~ASN1_STRFLGS_ESC_MSB & ~ASN1_STRFLGS_ESC_CTRL; @@ -618,6 +620,26 @@ int PasswordCallback(char* buf, int size, int rwflag, void* u) { return -1; } +struct StorePassphraseData { + Buffer passphrase{.data = nullptr, .len = 0}; + bool has_passphrase = false; + bool missing_passphrase = false; +}; + +int StorePasswordCallback(char* buf, int size, int rwflag, void* u) { + auto data = static_cast(u); + if (data == nullptr || !data->has_passphrase) { + if (data != nullptr) data->missing_passphrase = true; + return -1; + } + + size_t buflen = static_cast(size); + size_t len = data->passphrase.len; + if (buflen < len) return -1; + memcpy(buf, reinterpret_cast(data->passphrase.data), len); + return len; +} + // Algorithm: http://howardhinnant.github.io/date_algorithms.html constexpr int days_from_epoch(int y, unsigned m, unsigned d) { y -= m <= 2; @@ -2613,6 +2635,102 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey( }; } +EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryLoadPrivateKeyFromStore( + const StorePrivateKeyConfig& config) { +#if defined(OPENSSL_IS_BORINGSSL) || OPENSSL_VERSION_MAJOR < 3 + return ParseKeyResult(PKParseError::FAILED); +#else + ClearErrorOnReturn clear_error_on_return; + std::string uri_str(config.uri); + std::string properties_str; + const char* properties = nullptr; + if (config.properties.has_value()) { + properties_str.assign(config.properties->data(), config.properties->size()); + properties = properties_str.c_str(); + } + + std::string passphrase_str; + Buffer passbuf{.data = nullptr, .len = 0}; + if (config.passphrase.has_value()) { + passphrase_str.assign(config.passphrase->data, config.passphrase->len); + passbuf.data = passphrase_str.data(); + passbuf.len = passphrase_str.size(); + } + StorePassphraseData passphrase_data{ + .passphrase = passbuf, + .has_passphrase = config.passphrase.has_value(), + }; + UI_METHOD* ui_method = + UI_UTIL_wrap_read_pem_callback(StorePasswordCallback, 0); + if (ui_method == nullptr) return ParseKeyResult(PKParseError::FAILED); + + const OSSL_PARAM store_params[] = {OSSL_PARAM_END}; + OSSL_STORE_CTX* ctx = OSSL_STORE_open_ex(uri_str.c_str(), + nullptr, + properties, + ui_method, + &passphrase_data, + store_params, + nullptr, + nullptr); + if (ctx == nullptr) { + bool missing_passphrase = passphrase_data.missing_passphrase; + int err = ERR_peek_error(); + UI_destroy_method(ui_method); + if (missing_passphrase) { + return ParseKeyResult(PKParseError::NEED_PASSPHRASE); + } + return ParseKeyResult(PKParseError::FAILED, err); + } + + if (!OSSL_STORE_expect(ctx, OSSL_STORE_INFO_PKEY)) { + bool missing_passphrase = passphrase_data.missing_passphrase; + int err = ERR_peek_error(); + OSSL_STORE_close(ctx); + UI_destroy_method(ui_method); + if (missing_passphrase) { + return ParseKeyResult(PKParseError::NEED_PASSPHRASE); + } + return ParseKeyResult(PKParseError::FAILED, err); + } + + EVPKeyPointer pkey; + int store_error = 0; + while (!OSSL_STORE_eof(ctx)) { + OSSL_STORE_INFO* info = OSSL_STORE_load(ctx); + if (info == nullptr) { + if (OSSL_STORE_error(ctx)) { + store_error = ERR_peek_error(); + break; + } + continue; + } + if (OSSL_STORE_INFO_get_type(info) == OSSL_STORE_INFO_PKEY) { + EVP_PKEY* raw_pkey = OSSL_STORE_INFO_get1_PKEY(info); + if (raw_pkey != nullptr) { + pkey = EVPKeyPointer(raw_pkey); + } else { + store_error = ERR_peek_error(); + } + } + OSSL_STORE_INFO_free(info); + if (pkey || store_error != 0) break; + } + + OSSL_STORE_close(ctx); + UI_destroy_method(ui_method); + + if (passphrase_data.missing_passphrase) { + return ParseKeyResult(PKParseError::NEED_PASSPHRASE); + } + if (store_error != 0) { + return ParseKeyResult(PKParseError::FAILED, store_error); + } + if (!pkey) return ParseKeyResult(PKParseError::NOT_RECOGNIZED); + return ParseKeyResult(std::move(pkey)); +#endif +} + Result EVPKeyPointer::writePrivateKey( const PrivateKeyEncodingConfig& config) const { if (config.format == PKFormatType::JWK) { @@ -2638,6 +2756,8 @@ Result EVPKeyPointer::writePrivateKey( #else RSA* rsa = EVP_PKEY_get0_RSA(get()); #endif + if (rsa == nullptr) return Result(false); + switch (config.format) { case PKFormatType::PEM: { err = PEM_write_bio_RSAPrivateKey( @@ -2701,6 +2821,8 @@ Result EVPKeyPointer::writePrivateKey( #else EC_KEY* ec = EVP_PKEY_get0_EC_KEY(get()); #endif + if (ec == nullptr) return Result(false); + switch (config.format) { case PKFormatType::PEM: { err = PEM_write_bio_ECPrivateKey( @@ -2754,6 +2876,8 @@ Result EVPKeyPointer::writePublicKey( #else RSA* rsa = EVP_PKEY_get0_RSA(get()); #endif + if (rsa == nullptr) return Result(false); + if (config.format == ncrypto::EVPKeyPointer::PKFormatType::PEM) { // Encode PKCS#1 as PEM. if (PEM_write_bio_RSAPublicKey(bio.get(), rsa) != 1) { @@ -2773,7 +2897,17 @@ Result EVPKeyPointer::writePublicKey( if (config.format == ncrypto::EVPKeyPointer::PKFormatType::PEM) { // Encode SPKI as PEM. - if (PEM_write_bio_PUBKEY(bio.get(), get()) != 1) { + // Build the SubjectPublicKeyInfo wrapper explicitly before PEM encoding. + // Provider-backed keys can fail the direct PEM_write_bio_PUBKEY() path even + // when OpenSSL can materialize the public wrapper with X509_PUBKEY_set(). + X509_PUBKEY* pubkey = nullptr; + if (X509_PUBKEY_set(&pubkey, get()) != 1) { + X509_PUBKEY_free(pubkey); + return Result(false, + mark_pop_error_on_return.peekError()); + } + X509PubKeyPointer pubkey_ptr(pubkey); + if (PEM_write_bio_X509_PUBKEY(bio.get(), pubkey_ptr.get()) != 1) { return Result(false, mark_pop_error_on_return.peekError()); } @@ -2842,14 +2976,45 @@ std::optional EVPKeyPointer::getBytesOfRS() const { if (id == EVP_PKEY_DSA) { const DSA* dsa_key = EVP_PKEY_get0_DSA(get()); + bool has_bits = false; // Both r and s are computed mod q, so their width is limited by that of q. - bits = BignumPointer::GetBitCount(DSA_get0_q(dsa_key)); + if (dsa_key != nullptr) { + const BIGNUM* q = DSA_get0_q(dsa_key); + if (q != nullptr) { + bits = BignumPointer::GetBitCount(q); + has_bits = true; + } + } +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) + if (!has_bits && + EVP_PKEY_get_int_param(get(), OSSL_PKEY_PARAM_FFC_QBITS, &bits) == 1) { + has_bits = true; + } +#endif + if (!has_bits) return std::nullopt; } else if (id == EVP_PKEY_EC) { - bits = EC_GROUP_order_bits(ECKeyPointer::GetGroup(*this)); + const EC_KEY* ec_key = EVP_PKEY_get0_EC_KEY(get()); + bool has_bits = false; + if (ec_key != nullptr) { + const EC_GROUP* group = ECKeyPointer::GetGroup(ec_key); + if (group != nullptr) { + bits = EC_GROUP_order_bits(group); + has_bits = true; + } + } +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) + if (!has_bits && + EVP_PKEY_get_int_param(get(), OSSL_PKEY_PARAM_BITS, &bits) == 1) { + has_bits = true; + } +#endif + if (!has_bits) return std::nullopt; } else { return std::nullopt; } + if (bits <= 0) return std::nullopt; + return (bits + 7) / 8; } @@ -2880,16 +3045,18 @@ EVPKeyPointer::operator Dsa() const { bool EVPKeyPointer::validateDsaParameters() const { if (!pkey_) return false; - /* Validate DSA2 parameters from FIPS 186-4 */ #if OPENSSL_VERSION_MAJOR >= 3 if (EVP_default_properties_is_fips_enabled(nullptr) && EVP_PKEY_DSA == id()) { #else if (FIPS_mode() && EVP_PKEY_DSA == id()) { #endif + // Validate DSA2 parameters from FIPS 186-4. const DSA* dsa = EVP_PKEY_get0_DSA(pkey_.get()); + if (dsa == nullptr) return false; const BIGNUM* p; const BIGNUM* q; DSA_get0_pqg(dsa, &p, &q, nullptr); + if (p == nullptr || q == nullptr) return false; int L = BignumPointer::GetBitCount(p); int N = BignumPointer::GetBitCount(q); diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index a6befbf7cf6794..876f8470fed8c8 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -946,6 +946,7 @@ class EVPKeyPointer final { RAW_PUBLIC, RAW_PRIVATE, RAW_SEED, + STORE, }; enum class PKParseError { NOT_RECOGNIZED, NEED_PASSPHRASE, FAILED }; @@ -978,6 +979,12 @@ class EVPKeyPointer final { PrivateKeyEncodingConfig& operator=(const PrivateKeyEncodingConfig&); }; + struct StorePrivateKeyConfig { + std::string_view uri; + std::optional properties = std::nullopt; + std::optional> passphrase = std::nullopt; + }; + static ParseKeyResult TryParsePublicKey( const PublicKeyEncodingConfig& config, const Buffer& buffer); @@ -989,6 +996,13 @@ class EVPKeyPointer final { const PrivateKeyEncodingConfig& config, const Buffer& buffer); + // Loads a private key from an OpenSSL OSSL_STORE URI (e.g. "file:", a + // provider-backed scheme such as "pkcs11:"). The optional passphrase is + // used as the PIN/passphrase for encrypted or token-protected keys. + // Returns NOT_RECOGNIZED when no private key is found at the URI. + static ParseKeyResult TryLoadPrivateKeyFromStore( + const StorePrivateKeyConfig& config); + EVPKeyPointer() = default; explicit EVPKeyPointer(EVP_PKEY* pkey); EVPKeyPointer(EVPKeyPointer&& other) noexcept; diff --git a/doc/api/cli.md b/doc/api/cli.md index ef5734822bf877..ae7869f4993897 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -191,6 +191,21 @@ This behavior also applies to `child_process.spawn()`, but in that case, the flags are propagated via the `NODE_OPTIONS` environment variable rather than directly through the process arguments. +### `--allow-crypto-store` + + + +> Stability: 1.1 - Active development + +When using the [Permission Model][], the process will not be able to load +private keys from OpenSSL store URIs (passed as a {URL} to +[`crypto.createPrivateKey()`][]) by default. Attempts to do so will throw an +`ERR_ACCESS_DENIED` unless the user explicitly passes the +`--allow-crypto-store` flag. This permission can be dropped at runtime via +[`permission.drop()`][]. + ### `--allow-ffi` -* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView} - * `key` {string|ArrayBuffer|Buffer|TypedArray|DataView|Object} The key - material, either in PEM, DER, JWK, or raw format. +* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|URL} + * `key` {string|ArrayBuffer|Buffer|TypedArray|DataView|Object|URL} The key + material, either in PEM, DER, JWK, or raw format, or a {URL} referencing an + OpenSSL store. * `format` {string} Must be `'pem'`, `'der'`, `'jwk'`, `'raw-private'`, or `'raw-seed'`. **Default:** `'pem'`. * `type` {string} Must be `'pkcs1'`, `'pkcs8'` or `'sec1'`. This option is required only if the `format` is `'der'` and ignored otherwise. - * `passphrase` {string | Buffer} The passphrase to use for decryption. + * `passphrase` {string | Buffer} The passphrase to use for decryption. When + `key` is a {URL}, this is the optional PIN/passphrase forwarded to the + store. + * `properties` {string} The optional OpenSSL property query used when + fetching the store loader for a {URL} key. * `encoding` {string} The string encoding to use when `key` is a string. * `asymmetricKeyType` {string} Required when `format` is `'raw-private'` or `'raw-seed'` and ignored otherwise. @@ -4007,6 +4012,19 @@ must be an object with the properties described above. If the private key is encrypted, a `passphrase` must be specified. The length of the passphrase is limited to 1024 bytes. +#### OpenSSL store {URL} keys + +> Stability: 1.1 - Active development + +If `key` is a {URL} (or an object whose `key` is a {URL}), the private key is +loaded from the corresponding OpenSSL store URI (for example a `file:` URI or a +provider-backed scheme such as `pkcs11:`). When the [Permission Model][] is +enabled, [`--allow-crypto-store`][] is required. + +When `properties` is specified with a {URL} key, it is passed to OpenSSL as the +property query for selecting the store loader. It is not appended to the URL and +is distinct from provider-specific URI parameters. + ### `crypto.createPublicKey(key)`