From e496577046be29d479c4b219408daa4ad5436290 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 29 Jun 2026 14:54:03 +0200 Subject: [PATCH 1/3] 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 | 182 ++++- deps/ncrypto/ncrypto.h | 16 + doc/api/cli.md | 20 + doc/api/crypto.md | 37 +- 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 | 55 +- src/crypto/crypto_ec.cc | 30 +- src/crypto/crypto_keys.cc | 104 ++- src/crypto/crypto_rsa.cc | 89 ++- 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 | 710 ++++++++++++++++++ test/parallel/test-crypto-key-store.js | 94 +++ test/parallel/test-crypto-sign-verify.js | 35 + 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 +- 34 files changed, 1672 insertions(+), 80 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 cd9804d0d87d04..bc045cd4eaa5f6 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 771589ed69b421..0fdddc8f2a3b3c 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 @@ -21,6 +21,8 @@ #include #include #include +#include +#include #if OPENSSL_WITH_ARGON2 #include #endif @@ -75,6 +77,7 @@ namespace { using BignumCtxPointer = DeleteFnPtr; using BignumGenCallbackPointer = DeleteFnPtr; using NetscapeSPKIPointer = DeleteFnPtr; +using X509PubKeyPointer = DeleteFnPtr; const EVP_CIPHER* GetCipherCtxCipher(const EVP_CIPHER_CTX* ctx) { #if NCRYPTO_USE_OPENSSL3_PROVIDER @@ -830,6 +833,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; @@ -3627,6 +3650,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) { @@ -3668,6 +3787,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( @@ -3743,6 +3864,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( @@ -3809,6 +3932,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) { @@ -3837,10 +3962,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; } @@ -3911,21 +4054,37 @@ std::optional EVPKeyPointer::getBytesOfRS() const { bits = BignumPointer::GetBitCount(q.get()); #else 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 (!has_bits) return std::nullopt; #endif } else if (id == EVP_PKEY_EC) { #if NCRYPTO_USE_OPENSSL3_PROVIDER Ec ec(get()); if (!ec) return std::nullopt; - bits = EC_GROUP_order_bits(ec.getGroup()); + const EC_GROUP* group = ec.getGroup(); + if (group == nullptr) return std::nullopt; + bits = EC_GROUP_order_bits(group); #else - bits = EC_GROUP_order_bits(ECKeyPointer::GetGroup(*this)); + const EC_KEY* ec_key = EVP_PKEY_get0_EC_KEY(get()); + if (ec_key == nullptr) return std::nullopt; + const EC_GROUP* group = ECKeyPointer::GetGroup(ec_key); + if (group == nullptr) return std::nullopt; + bits = EC_GROUP_order_bits(group); #endif } else { return std::nullopt; } + if (bits <= 0) return std::nullopt; + return (bits + 7) / 8; } @@ -3964,12 +4123,12 @@ 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. #if NCRYPTO_USE_OPENSSL3_PROVIDER DeleteFnPtr p; DeleteFnPtr q; @@ -3981,9 +4140,11 @@ bool EVPKeyPointer::validateDsaParameters() const { const BIGNUM* q_value = q.get(); #else 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; const BIGNUM* p_value = p; const BIGNUM* q_value = q; #endif @@ -6337,9 +6498,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 92b36a15dd6250..bdd566a312bb28 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -1045,6 +1045,7 @@ class EVPKeyPointer final { RAW_PUBLIC, RAW_PRIVATE, RAW_SEED, + STORE, }; enum class PKParseError { NOT_RECOGNIZED, NEED_PASSPHRASE, FAILED }; @@ -1077,6 +1078,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); @@ -1088,6 +1095,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; @@ -1680,6 +1694,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 d3c8d415326593..75de74b5ff6984 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,24 @@ 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 a {URL} is used, `format`, `type`, `asymmetricKeyType`, and `namedCurve` +are ignored. The input is handled as an OpenSSL store URI, not as PEM, DER, JWK, +or raw key material. `passphrase` is still used as the optional PIN/passphrase +passed to the store, and `encoding` applies if that `passphrase` is a string. + +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)` -* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} +* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} * `dsaEncoding` {string} * `padding` {integer} * `saltLength` {integer} @@ -4022,9 +4022,12 @@ provider-backed scheme such as `pkcs11:`). When the [Permission Model][] is enabled, [`--allow-crypto-store`][] is required. When a {URL} is used, `format`, `type`, `asymmetricKeyType`, and `namedCurve` -are ignored. The input is handled as an OpenSSL store URI, not as PEM, DER, JWK, -or raw key material. `passphrase` is still used as the optional PIN/passphrase -passed to the store, and `encoding` applies if that `passphrase` is a string. +are ignored even when those options would otherwise depend on each other, such +as `type` with `format: 'der'` or `namedCurve` with +`asymmetricKeyType: 'ec'`. The input is handled as an OpenSSL store URI, not as +PEM, DER, JWK, or raw key material. `passphrase` is still used as the optional +PIN/passphrase passed to the store, and `encoding` applies if that `passphrase` +is a string. 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 @@ -4178,7 +4181,7 @@ algorithm names. added: v24.7.0 --> -* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} Private Key +* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} Private Key * `ciphertext` {ArrayBuffer|Buffer|TypedArray|DataView} * `callback` {Function} * `err` {Error} @@ -4222,7 +4225,7 @@ changes: --> * `options` {Object} - * `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} + * `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} * `publicKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} * `callback` {Function} * `err` {Error} @@ -5311,7 +5314,7 @@ changes: -* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} +* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} * `oaepHash` {string} The hash function to use for OAEP padding and MGF1. **Default:** `'sha1'` * `oaepLabel` {string|ArrayBuffer|Buffer|TypedArray|DataView} The label to @@ -5359,9 +5362,10 @@ changes: -* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} - * `key` {string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} - A PEM encoded private key. +* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} + * `key` {string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} + The private key material, a {KeyObject}, or a {URL} referencing an OpenSSL + store. * `passphrase` {string|ArrayBuffer|Buffer|TypedArray|DataView} An optional passphrase for the private key. * `padding` {crypto.constants} An optional padding value defined in @@ -6220,7 +6224,7 @@ changes: * `algorithm` {string | null | undefined} * `data` {ArrayBuffer|Buffer|SharedArrayBuffer|TypedArray|DataView|string} -* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} +* `key` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} * `callback` {Function} * `err` {Error} * `signature` {Buffer} diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index a16b138452326c..5d790b957fdb2b 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -511,7 +511,7 @@ function validateAsymmetricKeyType(type, ctx, key) { if (ctx === kCreatePrivate) { throw new ERR_INVALID_ARG_TYPE( 'key', - ['string', 'ArrayBuffer', 'Buffer', 'TypedArray', 'DataView'], + getKeyTypes({ allowKeyObject: false, allowURL: true }), key, ); } @@ -526,21 +526,20 @@ function validateAsymmetricKeyType(type, ctx, key) { } } -function getKeyTypes(allowKeyObject, bufferOnly = false) { - const types = [ - 'ArrayBuffer', - 'Buffer', - 'TypedArray', - 'DataView', - 'string', // Only if bufferOnly == false - 'KeyObject', // Only if allowKeyObject == true && bufferOnly == false +const kKeyTypesBufferOnly = [ + 'ArrayBuffer', + 'Buffer', + 'TypedArray', + 'DataView', +]; + +function getKeyTypes({ allowKeyObject, allowURL = false }) { + return [ + ...kKeyTypesBufferOnly, + 'string', + ...(allowKeyObject ? ['KeyObject'] : []), + ...(allowURL ? ['URL'] : []), ]; - if (bufferOnly) { - return ArrayPrototypeSlice(types, 0, 4); - } else if (!allowKeyObject) { - return ArrayPrototypeSlice(types, 0, 5); - } - return types; } function prepareStorePrivateKey(url, name, passphrase, encoding, properties) { @@ -580,13 +579,15 @@ function prepareStorePrivateKey(url, name, passphrase, encoding, properties) { } function prepareAsymmetricKey(key, ctx, name = 'key') { + const isPrivateKeyInput = ctx === kConsumePrivate || ctx === kCreatePrivate; + if (isKeyObject(key)) { // Best case: A key object, as simple as that. const type = getKeyObjectType(key); validateAsymmetricKeyType(type, ctx, key); return { data: getKeyObjectHandle(key) }; } - if ((ctx === kConsumePrivate || ctx === kCreatePrivate) && isURL(key)) { + if (isPrivateKeyInput && isURL(key)) { // A private key referenced by an OpenSSL store URI. return prepareStorePrivateKey(key, name); } @@ -604,7 +605,7 @@ function prepareAsymmetricKey(key, ctx, name = 'key') { validateAsymmetricKeyType(type, ctx, data); return { data: getKeyObjectHandle(data) }; } - if ((ctx === kConsumePrivate || ctx === kCreatePrivate) && isURL(data)) { + if (isPrivateKeyInput && isURL(data)) { // A private key referenced by an OpenSSL store URI, with an optional // passphrase/PIN. return prepareStorePrivateKey( @@ -646,7 +647,10 @@ function prepareAsymmetricKey(key, ctx, name = 'key') { if (!isStringOrBuffer(data)) { throw new ERR_INVALID_ARG_TYPE( `${name}.key`, - getKeyTypes(ctx !== kCreatePrivate), + getKeyTypes({ + allowKeyObject: ctx !== kCreatePrivate, + allowURL: isPrivateKeyInput, + }), data); } @@ -660,7 +664,10 @@ function prepareAsymmetricKey(key, ctx, name = 'key') { throw new ERR_INVALID_ARG_TYPE( name, - getKeyTypes(ctx !== kCreatePrivate), + getKeyTypes({ + allowKeyObject: ctx !== kCreatePrivate, + allowURL: isPrivateKeyInput, + }), key); } @@ -686,7 +693,7 @@ function prepareSecretKey(key, encoding, bufferOnly = false) { !isAnyArrayBuffer(key)) { throw new ERR_INVALID_ARG_TYPE( 'key', - getKeyTypes(!bufferOnly, bufferOnly), + bufferOnly ? kKeyTypesBufferOnly : getKeyTypes({ allowKeyObject: true }), key); } return getArrayBufferOrView(key, 'key', encoding); diff --git a/src/crypto/crypto_cipher.cc b/src/crypto/crypto_cipher.cc index a165e8e3409d6f..92682328d0ba2c 100644 --- a/src/crypto/crypto_cipher.cc +++ b/src/crypto/crypto_cipher.cc @@ -837,7 +837,11 @@ void PublicKeyCipher::Cipher(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); unsigned int offset = 0; - auto data = KeyObjectData::GetPublicOrPrivateKeyFromJs(args, &offset); + // TODO(panva): Use GetPrivateKeyFromJs() for private operations, then + // remove allow_private_key_store and URL handling from + // GetPublicOrPrivateKeyFromJs(). + auto data = KeyObjectData::GetPublicOrPrivateKeyFromJs( + args, &offset, operation == PublicKeyCipher::kPrivate); if (!data) return; const auto& pkey = data.GetAsymmetricKey(); if (!pkey) return; diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index 69c421b9737bdb..7cd464e7d078f7 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -845,7 +845,8 @@ KeyObjectData KeyObjectData::GetPrivateKeyFromJs( return {}; } - // JWK format: data is a JS Object (not buffer), format int is JWK. + // Object formats: data is a JS Object (not buffer), format int determines + // whether this is a JWK or an OpenSSL store descriptor. if (args[*offset]->IsObject() && !IsAnyBufferSource(args[*offset]) && args[*offset + 1]->IsInt32()) { auto format = static_cast( @@ -899,7 +900,9 @@ KeyObjectData KeyObjectData::GetPrivateKeyFromJs( } KeyObjectData KeyObjectData::GetPublicOrPrivateKeyFromJs( - const FunctionCallbackInfo& args, unsigned int* offset) { + const FunctionCallbackInfo& args, + unsigned int* offset, + bool allow_private_key_store) { Environment* env = Environment::GetCurrent(args); // JWK format: data is a JS Object (not buffer), format int is JWK. @@ -912,6 +915,14 @@ KeyObjectData KeyObjectData::GetPublicOrPrivateKeyFromJs( *offset += 5; return data; } + if (format == EVPKeyPointer::PKFormatType::STORE) { + if (allow_private_key_store) { + return GetPrivateKeyFromJs(args, offset, false); + } + THROW_ERR_INVALID_ARG_VALUE( + env, "OpenSSL store URLs are only accepted for private keys"); + return {}; + } } if (args[*offset]->IsString() || IsAnyBufferSource(args[*offset])) { diff --git a/src/crypto/crypto_keys.h b/src/crypto/crypto_keys.h index fd3b0b0d0fb7a7..1697b715f8e425 100644 --- a/src/crypto/crypto_keys.h +++ b/src/crypto/crypto_keys.h @@ -77,7 +77,9 @@ class KeyObjectData final : public MemoryRetainer { bool allow_key_object); static KeyObjectData GetPublicOrPrivateKeyFromJs( - const v8::FunctionCallbackInfo& args, unsigned int* offset); + const v8::FunctionCallbackInfo& args, + unsigned int* offset, + bool allow_private_key_store = false); static v8::Maybe GetPrivateKeyEncodingFromJs(const v8::FunctionCallbackInfo& args, diff --git a/test/parallel/test-crypto-key-store-pkcs11.js b/test/parallel/test-crypto-key-store-pkcs11.js index 3b6dfffd6e200f..f380f95d74a157 100644 --- a/test/parallel/test-crypto-key-store-pkcs11.js +++ b/test/parallel/test-crypto-key-store-pkcs11.js @@ -12,6 +12,9 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); const { + constants: { + RSA_PKCS1_PSS_PADDING, + }, createPublicKey, createPrivateKey, createSign, @@ -200,6 +203,9 @@ activate = 1 pin, ], { env }); + // Keep this fixture limited to key types that SoftHSM and pkcs11-provider can + // both generate and operate. Node's PQC APIs require OpenSSL >= 3.5, but that + // does not imply ML-DSA or ML-KEM support in this PKCS#11 stack. for (const [keyType, id, label] of [ ['RSA:2048', '01', 'node-rsa'], ['EC:prime256v1', '02', 'node-ec'], @@ -589,6 +595,10 @@ async function testRsa() { const publicKey = assertDerivedPublicKey(privateKey, 'rsa'); assertOneShotSignVerify('sha256', kData, privateKey); + assertOneShotSignVerify('sha256', kData, privateKey, { + padding: RSA_PKCS1_PSS_PADDING, + saltLength: 32, + }); assertStreamingSignOneShotVerify('sha256', kData, privateKey); assertInlineSignWithStoreUrl(privateKey); assertPublicExports(publicKey); @@ -604,6 +614,14 @@ async function testRsa() { { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, ['sign'], ['verify']); + + await assertWebCryptoSignVerify( + privateKey, + publicKey, + { name: 'RSA-PSS', hash: 'SHA-256' }, + ['sign'], + ['verify'], + { name: 'RSA-PSS', saltLength: 32 }); } async function testEc() { diff --git a/test/parallel/test-crypto-key-store.js b/test/parallel/test-crypto-key-store.js index 0e03effa57c36c..58d9a98bb16abb 100644 --- a/test/parallel/test-crypto-key-store.js +++ b/test/parallel/test-crypto-key-store.js @@ -2,8 +2,8 @@ const common = require('../common'); if (!common.hasCrypto) common.skip('missing crypto'); -const { hasOpenSSL3 } = require('../common/crypto'); -if (!hasOpenSSL3) +const { hasOpenSSL } = require('../common/crypto'); +if (!hasOpenSSL(3)) common.skip('requires OpenSSL 3.x'); // Verifies that crypto.createPrivateKey() can load a private key from an @@ -15,8 +15,17 @@ const fs = require('fs'); const path = require('path'); const { pathToFileURL } = require('url'); const { + createPublicKey, createPrivateKey, + createVerify, + decapsulate, + diffieHellman, + encapsulate, generateKeyPairSync, + privateDecrypt, + privateEncrypt, + publicDecrypt, + publicEncrypt, sign, verify, } = require('crypto'); @@ -53,6 +62,51 @@ const data = Buffer.from('hello store'); }); } +{ + const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + }); + const file = path.join(tmpdir.path, 'rsa.pem'); + fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' })); + const url = pathToFileURL(file); + const plaintext = Buffer.from('hello rsa store'); + + const ciphertext = publicEncrypt(publicKey, plaintext); + assert.deepStrictEqual(privateDecrypt(url, ciphertext), plaintext); + assert.deepStrictEqual(privateDecrypt({ key: url }, ciphertext), plaintext); + + const encrypted = privateEncrypt(url, plaintext); + assert.deepStrictEqual(publicDecrypt(publicKey, encrypted), plaintext); + + const encryptedFromObject = privateEncrypt({ key: url }, plaintext); + assert.deepStrictEqual(publicDecrypt(publicKey, encryptedFromObject), + plaintext); +} + +{ + const alice = generateKeyPairSync('x25519'); + const bob = generateKeyPairSync('x25519'); + const file = path.join(tmpdir.path, 'x25519.pem'); + fs.writeFileSync(file, alice.privateKey.export({ + format: 'pem', + type: 'pkcs8', + })); + const url = pathToFileURL(file); + + const expected = diffieHellman({ + privateKey: alice.privateKey, + publicKey: bob.publicKey, + }); + assert.deepStrictEqual( + diffieHellman({ privateKey: url, publicKey: bob.publicKey }), + expected); + + if (hasOpenSSL(3, 2)) { + const { sharedKey, ciphertext } = encapsulate(alice.publicKey); + assert.deepStrictEqual(decapsulate(url, ciphertext), sharedKey); + } +} + { // Encrypted PKCS#8 with passphrase via { key: url, passphrase }. const { privateKey, publicKey } = generateKeyPairSync('ed25519'); @@ -79,8 +133,48 @@ const data = Buffer.from('hello store'); { // A URL is only accepted in private-key contexts. const url = pathToFileURL(path.join(tmpdir.path, 'priv.pem')); - assert.throws(() => require('crypto').createPublicKey(url), { + assert.throws(() => createPublicKey(url), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => createPublicKey({ key: url }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => publicEncrypt(url, data), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => publicEncrypt({ key: url }, data), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => publicDecrypt(url, Buffer.alloc(0)), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => verify(null, data, url, Buffer.alloc(0)), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => verify(null, data, { key: url }, Buffer.alloc(0)), { + code: 'ERR_INVALID_ARG_TYPE', + }); + const verifier = createVerify('sha256'); + verifier.update(data); + assert.throws(() => verifier.verify(url, Buffer.alloc(0)), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => encapsulate(url), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => encapsulate({ key: url }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + assert.throws(() => diffieHellman({ + privateKey: createPrivateKey(url), + publicKey: url, + }), { + code: 'ERR_INVALID_ARG_TYPE', + }); + + assert.throws(() => createPrivateKey(1), { code: 'ERR_INVALID_ARG_TYPE', + message: /URL/, }); } From b1d5d842ff56286695cc4aa37845087bf7d10cf8 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 9 Jul 2026 10:14:18 +0200 Subject: [PATCH 3/3] fixup! crypto: support OpenSSL STORE private keys --- src/crypto/crypto_sig.cc | 38 +++++++++++----- test/parallel/test-crypto-key-store-pkcs11.js | 43 +++---------------- test/parallel/test-crypto-sign-verify.js | 10 ++--- 3 files changed, 37 insertions(+), 54 deletions(-) diff --git a/src/crypto/crypto_sig.cc b/src/crypto/crypto_sig.cc index 8d44198f22f3fa..7e914d70d90ffb 100644 --- a/src/crypto/crypto_sig.cc +++ b/src/crypto/crypto_sig.cc @@ -18,6 +18,7 @@ using ncrypto::ClearErrorOnReturn; using ncrypto::DataPointer; using ncrypto::Digest; using ncrypto::ECDSASigPointer; +using ncrypto::ECKeyPointer; using ncrypto::EVPKeyCtxPointer; using ncrypto::EVPKeyPointer; using ncrypto::EVPMDCtxPointer; @@ -391,16 +392,31 @@ bool SupportsContextString(const EVPKeyPointer& key) { return false; } -bool CanUseRsaPrehashedFallback(const EVPKeyPointer& key, - const Digest& digest, - bool has_context) { - // The primary one-shot path is OpenSSL's digest-sign operation. For RSA, - // falling back to the lower-level prehashed sign/verify path preserves the - // same signature semantics. That is not generally true for EC-family keys: - // the streaming Sign API already uses this path, and tests intentionally - // preserve cases where streaming and one-shot signatures are not equivalent. - return digest && !has_context && - (key.id() == EVP_PKEY_RSA || key.id() == EVP_PKEY_RSA_PSS); +bool IsSM2Key(const EVPKeyPointer& key) { +#ifdef OPENSSL_IS_BORINGSSL + return false; +#else + if (key.id() == EVP_PKEY_SM2) return true; + if (key.id() != EVP_PKEY_EC) return false; + + ECKeyPointer ec(key); + if (!ec) return false; + + const EC_GROUP* group = ec.getGroup(); + return group != nullptr && EC_GROUP_get_curve_name(group) == NID_sm2; +#endif +} + +bool CanUsePrehashedFallback(const EVPKeyPointer& key, + const Digest& digest, + bool has_context) { + if (!digest || has_context) return false; + + if (key.isRsaVariant()) return true; + + // SM2 digest signing first hashes the algorithm-specific Z value, so the + // lower-level prehashed sign/verify operation is not equivalent. + return key.isSigVariant() && !IsSM2Key(key); } ByteSource SignPrehashed(Environment* env, @@ -905,7 +921,7 @@ bool SignTraits::DeriveBits(Environment* env, ? std::optional(params.salt_length) : std::nullopt; bool can_fallback_to_prehash = - CanUseRsaPrehashedFallback(key, params.digest, has_context); + CanUsePrehashedFallback(key, params.digest, has_context); auto context = EVPMDCtxPointer::New(); if (!context) [[unlikely]] diff --git a/test/parallel/test-crypto-key-store-pkcs11.js b/test/parallel/test-crypto-key-store-pkcs11.js index f380f95d74a157..48c53949a5b3bb 100644 --- a/test/parallel/test-crypto-key-store-pkcs11.js +++ b/test/parallel/test-crypto-key-store-pkcs11.js @@ -464,31 +464,6 @@ async function assertPrivateCryptoKeyExportsRejected( } } -async function assertWebCryptoSignRejected( - privateKey, - algorithm, - privateUsages, - signAlgorithm = algorithm.name, -) { - const privateCryptoKey = privateKey.toCryptoKey( - algorithm, - false, - privateUsages); - assert.strictEqual(privateCryptoKey.type, 'private'); - assert.strictEqual(privateCryptoKey.extractable, false); - assert.deepStrictEqual(privateCryptoKey.usages, privateUsages); - - await assert.rejects( - subtle.sign(signAlgorithm, privateCryptoKey, kData), - (err) => { - assert.strictEqual(err.name, 'OperationError'); - assert.strictEqual( - err.cause?.code, - 'ERR_OSSL_EVP_PROVIDER_SIGNATURE_FAILURE'); - return true; - }); -} - function assertStoreOptions() { assert.strictEqual( createPrivateKey({ @@ -629,16 +604,10 @@ async function testEc() { assertKeyDetails(privateKey, 'private', 'ec'); const publicKey = assertDerivedPublicKey(privateKey, 'ec'); - assert.throws(() => { - sign('sha256', kData, privateKey); - }, { code: 'ERR_OSSL_EVP_PROVIDER_SIGNATURE_FAILURE' }); - - assert.throws(() => { - sign('sha256', kData, { - key: privateKey, - dsaEncoding: 'ieee-p1363', - }); - }, { code: 'ERR_OSSL_EVP_PROVIDER_SIGNATURE_FAILURE' }); + assertOneShotSignVerify('sha256', kData, privateKey); + assertOneShotSignVerify('sha256', kData, privateKey, { + dsaEncoding: 'ieee-p1363', + }); assertPublicExports(publicKey); assertPrivateExportsRejected(privateKey, 'ec'); @@ -646,10 +615,12 @@ async function testEc() { privateKey, { name: 'ECDSA', namedCurve: 'P-256' }, ['sign']); - await assertWebCryptoSignRejected( + await assertWebCryptoSignVerify( privateKey, + publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, ['sign'], + ['verify'], { name: 'ECDSA', hash: 'SHA-256' }); } diff --git a/test/parallel/test-crypto-sign-verify.js b/test/parallel/test-crypto-sign-verify.js index c7e92cd7e5e8e2..527c39b30786aa 100644 --- a/test/parallel/test-crypto-sign-verify.js +++ b/test/parallel/test-crypto-sign-verify.js @@ -769,13 +769,9 @@ MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa // RSA-PSS Sign test by verifying with 'openssl dgst -verify' -// Note: this particular test *must* be the last in this file as it will exit -// early if no openssl binary is found -{ - if (!opensslCli) { - common.skip('node compiled without OpenSSL CLI.'); - } - +if (!opensslCli) { + common.printSkipMessage('node compiled without OpenSSL CLI.'); +} else { const pubfile = fixtures.path('keys', 'rsa_public_2048.pem'); const privkey = fixtures.readKey('rsa_private_2048.pem');