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..8b06b606461367 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -4,13 +4,13 @@ #include #include #include +#include #include #include #include #if NCRYPTO_USE_BORINGSSL_EVP_DO_ALL_FALLBACK #include #include -#include #endif #include #include @@ -20,6 +20,8 @@ #include #include #include +#include +#include #if OPENSSL_WITH_ARGON2 #include #endif @@ -74,6 +76,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 +621,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 +2636,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 +2757,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 +2822,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 +2877,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,10 +2898,28 @@ Result EVPKeyPointer::writePublicKey( if (config.format == ncrypto::EVPKeyPointer::PKFormatType::PEM) { // Encode SPKI as PEM. +#if OPENSSL_VERSION_MAJOR == 3 + // 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()); + } +#else + // Non-OpenSSL 3 builds do not all declare PEM_write_bio_X509_PUBKEY(). if (PEM_write_bio_PUBKEY(bio.get(), get()) != 1) { return Result(false, mark_pop_error_on_return.peekError()); } +#endif return bio; } @@ -2842,14 +2985,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 +3054,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); @@ -4617,9 +4793,14 @@ DataPointer EVPMDCtxPointer::sign( bool EVPMDCtxPointer::verify(const Buffer& buf, const Buffer& sig) const { - if (!ctx_) return false; - int ret = EVP_DigestVerify(ctx_.get(), sig.data, sig.len, buf.data, buf.len); - return ret == 1; + return verifyOneShot(buf, sig) == 1; +} + +int EVPMDCtxPointer::verifyOneShot( + const Buffer& buf, + const Buffer& sig) const { + if (!ctx_) return -1; + return EVP_DigestVerify(ctx_.get(), sig.data, sig.len, buf.data, buf.len); } EVPMDCtxPointer EVPMDCtxPointer::New() { diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index a6befbf7cf6794..029aa5539edc5b 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; @@ -1517,6 +1531,8 @@ class EVPMDCtxPointer final { DataPointer sign(const Buffer& buf) const; bool verify(const Buffer& buf, const Buffer& sig) const; + int verifyOneShot(const Buffer& buf, + const Buffer& sig) const; const EVP_MD* getDigest() const; size_t getDigestSize() const; 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)`