From 70d8d79a6e65b268cc0419aa591f15c79a646f62 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 29 Jun 2026 14:54:03 +0200 Subject: [PATCH] crypto: support loading private keys through STORE loaders Accept WHATWG URL objects in private-key inputs and load referenced keys through OpenSSL STORE loaders. Pass optional property queries and passphrases while preserving provider-owned EVP_PKEY objects for ordinary KeyObject and CryptoKey operations. 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 | 17 + doc/api/cli.md | 24 + doc/api/crypto.md | 66 +- doc/api/permissions.md | 11 +- doc/api/process.md | 2 + doc/node-config-schema.json | 8 + doc/node.1 | 14 + lib/internal/crypto/keys.js | 90 ++- lib/internal/process/permission.js | 1 + lib/internal/process/pre_execution.js | 1 + node.gyp | 2 + src/crypto/crypto_cipher.cc | 6 +- src/crypto/crypto_dsa.cc | 55 +- src/crypto/crypto_ec.cc | 30 +- src/crypto/crypto_keys.cc | 122 ++- src/crypto/crypto_keys.h | 4 +- src/crypto/crypto_rsa.cc | 89 ++- src/crypto/crypto_sig.cc | 137 +++- 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 | 699 ++++++++++++++++++ test/parallel/test-crypto-key-store.js | 188 +++++ test/parallel/test-crypto-sign-verify.js | 45 +- test/parallel/test-permission-crypto-store.js | 91 +++ test/parallel/test-permission-has.js | 1 + .../parallel/test-permission-warning-flags.js | 1 + typings/internalBinding/crypto.d.ts | 11 +- 36 files changed, 1882 insertions(+), 117 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..8165713d3105c2 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,14 @@ class EVPKeyPointer final { const PrivateKeyEncodingConfig& config, const Buffer& buffer); + // Loads a private key through an OpenSSL STORE loader using the configured + // 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 +1695,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..3e5c15ac5f6bac 100644 --- a/doc/api/cli.md +++ b/doc/api/cli.md @@ -191,6 +191,25 @@ 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 through OpenSSL STORE loaders (using a {URL} passed 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()`][]. + +This flag grants broad authority to configured OpenSSL STORE loaders. A loader +may access files, devices, tokens, or the network. Access performed by a loader +is not constrained by the `fs.read`, `fs.write`, or `net` permission scopes. + ### `--allow-ffi` -* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject} +* `privateKey` {Object|string|ArrayBuffer|Buffer|TypedArray|DataView|KeyObject|URL} * `dsaEncoding` {string} * `padding` {integer} * `saltLength` {integer} @@ -3982,14 +3982,19 @@ changes: -* `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 + object for an OpenSSL STORE loader. * `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 loader. + * `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,36 @@ 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. +#### Private keys from OpenSSL STORE loaders + +> Stability: 1.1 - Active development + +If `key` is a {URL} (or an object whose `key` is a {URL}), the private key is +loaded through an OpenSSL STORE loader. The URL is passed to OpenSSL as a 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. + +Configured OpenSSL STORE loaders have broad authority and may access files, +devices, tokens, or the network. Access performed by a loader is not constrained +by the `fs.read`, `fs.write`, or `net` permission scopes. + +When a {URL} is used, `format`, `type`, `asymmetricKeyType`, and `namedCurve` +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 passed to the STORE loader as a URI, +not handled as PEM, DER, JWK, or raw key material. `passphrase` is still used as +the optional PIN/passphrase passed to the loader, and `encoding` applies if that +`passphrase` is a string. + +Use `passphrase` instead of embedding credentials in the URI passed to the +STORE loader. Node.js redacts the URI from its own permission-denial resource +and diagnostics. Errors reported by OpenSSL or a provider after loading begins +may include the URI. + +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)` -* `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} @@ -4195,7 +4234,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} @@ -5284,7 +5323,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 @@ -5332,9 +5371,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 object + for an OpenSSL STORE loader. * `passphrase` {string|ArrayBuffer|Buffer|TypedArray|DataView} An optional passphrase for the private key. * `padding` {crypto.constants} An optional padding value defined in @@ -6193,7 +6233,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} @@ -6960,6 +7000,7 @@ See the [list of SSL OP Flags][] for details. [NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf [OpenSSL's FIPS README file]: https://github.com/openssl/openssl/blob/openssl-3.0/README-FIPS.md [OpenSSL's SPKAC implementation]: https://www.openssl.org/docs/man3.0/man1/openssl-spkac.html +[Permission Model]: permissions.md#permission-model [RFC 1421]: https://www.rfc-editor.org/rfc/rfc1421.txt [RFC 2409]: https://www.rfc-editor.org/rfc/rfc2409.txt [RFC 2818]: https://www.rfc-editor.org/rfc/rfc2818.txt @@ -6973,6 +7014,7 @@ See the [list of SSL OP Flags][] for details. [RFC 8032]: https://www.rfc-editor.org/rfc/rfc8032.txt [RFC 9562]: https://www.rfc-editor.org/rfc/rfc9562.txt [Web Crypto API documentation]: webcrypto.md +[`--allow-crypto-store`]: cli.md#--allow-crypto-store [`BN_is_prime_ex`]: https://www.openssl.org/docs/man1.1.1/man3/BN_is_prime_ex.html [`Buffer`]: buffer.md [`DH_generate_key()`]: https://www.openssl.org/docs/man3.0/man3/DH_generate_key.html diff --git a/doc/api/permissions.md b/doc/api/permissions.md index 3f2a6411d3f678..33893387fed75d 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -74,6 +74,12 @@ flag. For WASI, use the [`--allow-wasi`][] flag. For FFI, use the [`--allow-ffi`][] flag. The [`node:ffi`](ffi.md) module also requires the `--experimental-ffi` flag and is only available in builds with FFI support. +To allow loading private keys through OpenSSL STORE loaders via +[`crypto.createPrivateKey()`][], use the [`--allow-crypto-store`][] flag. +This flag grants broad authority to configured OpenSSL STORE loaders, which may +access files, devices, tokens, or the network. Access performed by a loader is +not constrained by the `fs.read`, `fs.write`, or `net` permission scopes. + #### Runtime API When enabling the Permission Model through the [`--permission`][] @@ -208,7 +214,8 @@ Example `node.config.json`: "allow-worker": true, "allow-net": true, "allow-addons": false, - "allow-ffi": false + "allow-ffi": false, + "allow-crypto-store": false } } ``` @@ -318,6 +325,7 @@ Developers relying on --permission to sandbox untrusted code should be aware tha [Security Policy]: https://github.com/nodejs/node/blob/main/SECURITY.md [`--allow-addons`]: cli.md#--allow-addons [`--allow-child-process`]: cli.md#--allow-child-process +[`--allow-crypto-store`]: cli.md#--allow-crypto-store [`--allow-ffi`]: cli.md#--allow-ffi [`--allow-fs-read`]: cli.md#--allow-fs-read [`--allow-fs-write`]: cli.md#--allow-fs-write @@ -325,5 +333,6 @@ Developers relying on --permission to sandbox untrusted code should be aware tha [`--allow-wasi`]: cli.md#--allow-wasi [`--allow-worker`]: cli.md#--allow-worker [`--permission`]: cli.md#--permission +[`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey [`npx`]: https://docs.npmjs.com/cli/commands/npx [`permission.has()`]: process.md#processpermissionhasscope-reference diff --git a/doc/api/process.md b/doc/api/process.md index 0a7f85700ae85a..0ec9432e01c246 100644 --- a/doc/api/process.md +++ b/doc/api/process.md @@ -3158,6 +3158,7 @@ The available scopes are: * `fs.read` - File System read operations * `fs.write` - File System write operations * `child` - Child process spawning operations +* `crypto.store` - Private key loading through OpenSSL STORE loaders * `worker` - Worker thread spawning operation * `ffi` - Foreign function interface operations @@ -3208,6 +3209,7 @@ The available scopes are the same as [`process.permission.has()`][]: * `fs.read` - File System read operations * `fs.write` - File System write operations * `child` - Child process spawning operations +* `crypto.store` - Private key loading through OpenSSL STORE loaders * `worker` - Worker thread spawning operation * `net` - Network operations * `inspector` - Inspector operations diff --git a/doc/node-config-schema.json b/doc/node-config-schema.json index 414943e571d839..82d1a9d041a167 100644 --- a/doc/node-config-schema.json +++ b/doc/node-config-schema.json @@ -61,6 +61,10 @@ "type": "boolean", "description": "allow use of child process when any permissions are set" }, + "allow-crypto-store": { + "type": "boolean", + "description": "allow loading private keys through OpenSSL STORE loaders when any permissions are set" + }, "allow-ffi": { "type": "boolean", "description": "allow use of FFI when any permissions are set" @@ -826,6 +830,10 @@ "type": "boolean", "description": "allow use of child process when any permissions are set" }, + "allow-crypto-store": { + "type": "boolean", + "description": "allow loading private keys through OpenSSL STORE loaders when any permissions are set" + }, "allow-ffi": { "type": "boolean", "description": "allow use of FFI when any permissions are set" diff --git a/doc/node.1 b/doc/node.1 index 300f58ca4aa140..59445e254b3b02 100644 --- a/doc/node.1 +++ b/doc/node.1 @@ -118,6 +118,16 @@ This behavior also applies to \fBchild_process.spawn()\fR, but in that case, the flags are propagated via the \fBNODE_OPTIONS\fR environment variable rather than directly through the process arguments. . +.It Fl -allow-crypto-store +When using the Permission Model, the process will not be able to load +private keys through OpenSSL STORE loaders (using a \fB\fR passed to \fBcrypto.createPrivateKey()\fR) by default. Attempts to do so will throw an +\fBERR_ACCESS_DENIED\fR unless the user explicitly passes the +\fB--allow-crypto-store\fR flag. This permission can be dropped at runtime via +\fBpermission.drop()\fR. +This flag grants broad authority to configured OpenSSL STORE loaders. A loader +may access files, devices, tokens, or the network. Access performed by a loader +is not constrained by the \fBfs.read\fR, \fBfs.write\fR, or \fBnet\fR permission scopes. +. .It Fl -allow-ffi When using the Permission Model, the process will not be able to use FFI APIs by default. Attempts to use FFI APIs will throw an \fBERR_ACCESS_DENIED\fR @@ -1185,6 +1195,8 @@ WASI - manageable through \fB--allow-wasi\fR flag Addons - manageable through \fB--allow-addons\fR flag .It FFI - manageable through \fB--allow-ffi\fR flag +.It +Crypto Store - manageable through \fB--allow-crypto-store\fR flag .El . .It Fl -permission-audit @@ -1900,6 +1912,8 @@ one is included in the list below. .It \fB--allow-child-process\fR .It +\fB--allow-crypto-store\fR +.It \fB--allow-ffi\fR .It \fB--allow-fs-read\fR diff --git a/lib/internal/crypto/keys.js b/lib/internal/crypto/keys.js index 250f99cfd20d1a..63017860695dce 100644 --- a/lib/internal/crypto/keys.js +++ b/lib/internal/crypto/keys.js @@ -27,6 +27,7 @@ const { kKeyFormatRawPublic, kKeyFormatRawPrivate, kKeyFormatRawSeed, + kKeyFormatStore, kKeyEncodingPKCS1, kKeyEncodingPKCS8, kKeyEncodingSPKI, @@ -72,6 +73,8 @@ const { isArrayBufferView, } = require('internal/util/types'); +const { fileURLToPath, isURL, URL } = require('internal/url'); + const { customInspectSymbol: kInspect, kEnumerableProperty, @@ -506,7 +509,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, ); } @@ -521,37 +524,70 @@ 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) { + const normalizedURL = new URL(url.href); + let uri = normalizedURL.href; + if (normalizedURL.protocol === 'file:') + uri = fileURLToPath(normalizedURL); + + if (passphrase !== undefined) { + if (!isStringOrBuffer(passphrase)) { + throw new ERR_INVALID_ARG_VALUE(option('passphrase', name), passphrase); + } + passphrase = getArrayBufferOrView( + passphrase, + option('passphrase', name), + encoding); + } + + if (properties !== undefined) + validateString(properties, option('properties', name)); + + return { + data: { + uri, + properties: properties ?? null, + }, + format: kKeyFormatStore, + passphrase: passphrase ?? null, + }; +} 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 (isPrivateKeyInput && isURL(key)) { + // A private key referenced by a URI for an OpenSSL STORE loader. + return prepareStorePrivateKey(key, name); + } if (isStringOrBuffer(key)) { // Expect PEM by default, mostly for backward compatibility. return { format: kKeyFormatPEM, data: getArrayBufferOrView(key, name) }; } if (typeof key === 'object') { - const { key: data, encoding, format } = key; + const { key: data, encoding, format, properties } = key; // The 'key' property can be a KeyObject as well to allow specifying // additional options such as padding along with the key. @@ -560,6 +596,16 @@ function prepareAsymmetricKey(key, ctx, name = 'key') { validateAsymmetricKeyType(type, ctx, data); return { data: getKeyObjectHandle(data) }; } + if (isPrivateKeyInput && isURL(data)) { + // A private key referenced by a URI for an OpenSSL STORE loader, with + // an optional passphrase/PIN. + return prepareStorePrivateKey( + data, + name, + key.passphrase, + encoding, + properties); + } if (format === 'jwk') { validateObject(data, `${name}.key`); return { data, format: kKeyFormatJWK }; @@ -592,7 +638,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); } @@ -606,7 +655,10 @@ function prepareAsymmetricKey(key, ctx, name = 'key') { throw new ERR_INVALID_ARG_TYPE( name, - getKeyTypes(ctx !== kCreatePrivate), + getKeyTypes({ + allowKeyObject: ctx !== kCreatePrivate, + allowURL: isPrivateKeyInput, + }), key); } @@ -632,7 +684,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/lib/internal/process/permission.js b/lib/internal/process/permission.js index 78e10e6e15fd0d..9bc57da3dca1ba 100644 --- a/lib/internal/process/permission.js +++ b/lib/internal/process/permission.js @@ -70,6 +70,7 @@ module.exports = ObjectFreeze({ '--allow-inspector', '--allow-wasi', '--allow-worker', + '--allow-crypto-store', ]; if (_ffi) { diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index 38ea6675928ad8..57f5b777df32b0 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -684,6 +684,7 @@ function initializePermission() { const warnFlags = [ '--allow-addons', '--allow-child-process', + '--allow-crypto-store', '--allow-inspector', '--allow-wasi', '--allow-worker', diff --git a/node.gyp b/node.gyp index 368b621b1239b0..1204033dbd26b3 100644 --- a/node.gyp +++ b/node.gyp @@ -179,6 +179,7 @@ 'src/node_zlib.cc', 'src/path.cc', 'src/permission/child_process_permission.cc', + 'src/permission/crypto_store_permission.cc', 'src/permission/ffi_permission.cc', 'src/permission/fs_permission.cc', 'src/permission/inspector_permission.cc', @@ -317,6 +318,7 @@ 'src/node_worker.h', 'src/path.h', 'src/permission/child_process_permission.h', + 'src/permission/crypto_store_permission.h', 'src/permission/ffi_permission.h', 'src/permission/fs_permission.h', 'src/permission/inspector_permission.h', 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_dsa.cc b/src/crypto/crypto_dsa.cc index c2ae2eceb2cf2f..03b5b4aa581ccb 100644 --- a/src/crypto/crypto_dsa.cc +++ b/src/crypto/crypto_dsa.cc @@ -1,19 +1,24 @@ #include "crypto/crypto_dsa.h" +#include "async_wrap-inl.h" #include "crypto/crypto_keys.h" #include "crypto/crypto_util.h" -#include "async_wrap-inl.h" #include "env-inl.h" #include "memory_tracker-inl.h" #include "threadpoolwork-inl.h" #include "v8.h" #include +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) +#include +#endif #include +#include #include namespace node { +using ncrypto::BignumPointer; using ncrypto::Dsa; using ncrypto::EVPKeyCtxPointer; using v8::FunctionCallbackInfo; @@ -62,7 +67,7 @@ Maybe DsaKeyGenTraits::AdditionalConfig( const FunctionCallbackInfo& args, unsigned int* offset, DsaKeyPairGenConfig* params) { - CHECK(args[*offset]->IsUint32()); // modulus bits + CHECK(args[*offset]->IsUint32()); // modulus bits CHECK(args[*offset + 1]->IsInt32()); // divisor bits params->params.modulus_bits = args[*offset].As()->Value(); @@ -74,16 +79,10 @@ Maybe DsaKeyGenTraits::AdditionalConfig( return JustVoid(); } -bool GetDsaKeyDetail(Environment* env, - const KeyObjectData& key, - Local target) { - if (!key) return false; - Dsa dsa = key.GetAsymmetricKey(); - if (!dsa) return false; - - size_t modulus_length = dsa.getModulusLength(); - size_t divisor_length = dsa.getDivisorLength(); - +static bool SetDsaKeyDetail(Environment* env, + Local target, + size_t modulus_length, + size_t divisor_length) { return target ->Set(env->context(), env->modulus_length_string(), @@ -98,6 +97,38 @@ bool GetDsaKeyDetail(Environment* env, .IsJust(); } +bool GetDsaKeyDetail(Environment* env, + const KeyObjectData& key, + Local target) { + if (!key) return false; + const auto& m_pkey = key.GetAsymmetricKey(); + Dsa dsa = m_pkey; + if (!dsa) { +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) + BIGNUM* p = nullptr; + BIGNUM* q = nullptr; + BignumPointer p_ptr; + BignumPointer q_ptr; + if (EVP_PKEY_get_bn_param(m_pkey.get(), OSSL_PKEY_PARAM_FFC_P, &p) == 1) { + p_ptr.reset(p); + } + if (EVP_PKEY_get_bn_param(m_pkey.get(), OSSL_PKEY_PARAM_FFC_Q, &q) == 1) { + q_ptr.reset(q); + } + if (p_ptr && q_ptr) { + return SetDsaKeyDetail(env, + target, + BignumPointer::GetBitCount(p_ptr.get()), + BignumPointer::GetBitCount(q_ptr.get())); + } +#endif + return false; + } + + return SetDsaKeyDetail( + env, target, dsa.getModulusLength(), dsa.getDivisorLength()); +} + namespace DSAAlg { void Initialize(Environment* env, Local target) { DsaKeyPairGenJob::Initialize(env, target); diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc index 38f6831e7a9dee..fc6077f16a6f75 100644 --- a/src/crypto/crypto_ec.cc +++ b/src/crypto/crypto_ec.cc @@ -11,8 +11,12 @@ #include "v8.h" #include +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) +#include +#endif #include #include +#include #include @@ -471,8 +475,7 @@ bool ExportJWKEcKey(Environment* env, CHECK_EQ(m_pkey.id(), EVP_PKEY_EC); ECKeyPointer ec(m_pkey); - if (!ec) { - THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK EC key"); + if (!ec || ec.getPublicKey() == nullptr) { return false; } @@ -481,7 +484,7 @@ bool ExportJWKEcKey(Environment* env, int degree_bits = EC_GROUP_get_degree(group); int degree_bytes = - (degree_bits / CHAR_BIT) + (7 + (degree_bits % CHAR_BIT)) / 8; + (degree_bits / CHAR_BIT) + (7 + (degree_bits % CHAR_BIT)) / 8; auto x = BignumPointer::New(); auto y = BignumPointer::New(); @@ -543,6 +546,7 @@ bool ExportJWKEcKey(Environment* env, if (key.GetKeyType() == kKeyTypePrivate) { auto pvt = ec.getPrivateKey(); + if (pvt == nullptr) return false; return SetEncodedValue(env, target, env->jwk_d_string(), pvt, degree_bytes) .IsJust(); } @@ -756,7 +760,25 @@ bool GetEcKeyDetail(Environment* env, CHECK_EQ(m_pkey.id(), EVP_PKEY_EC); ECKeyPointer ec(m_pkey); - if (!ec) return true; + if (!ec) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + char group_name[80]; + size_t group_name_len = 0; + if (EVP_PKEY_get_utf8_string_param(m_pkey.get(), + OSSL_PKEY_PARAM_GROUP_NAME, + group_name, + sizeof(group_name), + &group_name_len) == 1 && + group_name_len > 0) { + return target + ->Set(env->context(), + env->named_curve_string(), + OneByteString(env->isolate(), group_name, group_name_len)) + .IsJust(); + } +#endif + return true; + } const auto group = ec.getGroup(); int nid = EC_GROUP_get_curve_name(group); diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index 1404390f4bc8bc..36b6abf36731fd 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -12,6 +12,7 @@ #include "memory_tracker-inl.h" #include "node.h" #include "node_buffer.h" +#include "permission/permission.h" #include "string_bytes.h" #include "threadpoolwork-inl.h" #include "util-inl.h" @@ -386,8 +387,9 @@ bool KeyObjectData::ToEncodedPublicKey( const auto& pkey = GetAsymmetricKey(); if (pkey.id() == EVP_PKEY_EC) { ECKeyPointer ec_key(pkey); - if (!ec_key) { - THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + if (!ec_key || ec_key.getPublicKey() == nullptr) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC public key"); return false; } auto form = static_cast(config.ec_point_form); @@ -436,12 +438,14 @@ bool KeyObjectData::ToEncodedPrivateKey( if (pkey.id() == EVP_PKEY_EC) { ECKeyPointer ec_key(pkey); if (!ec_key) { - THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC private key"); return false; } const BIGNUM* private_key = ec_key.getPrivateKey(); if (private_key == nullptr) { - THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to get EC private key"); + THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC private key"); return false; } const auto group = ec_key.getGroup(); @@ -764,7 +768,87 @@ KeyObjectData KeyObjectData::GetPrivateKeyFromJs( bool allow_key_object) { Environment* env = Environment::GetCurrent(args); - // JWK format: data is a JS Object (not buffer), format int is JWK. + // Store descriptor: data is a { uri, properties } object, format int is + // STORE, and the passphrase slot carries an optional passphrase/PIN. + if (args[*offset]->IsObject() && !IsAnyBufferSource(args[*offset]) && + args[*offset + 1]->IsInt32() && + static_cast( + args[*offset + 1].As()->Value()) == + EVPKeyPointer::PKFormatType::STORE) { + Local store = args[*offset].As(); + Local uri_value; + if (!store + ->Get(env->context(), FIXED_ONE_BYTE_STRING(env->isolate(), "uri")) + .ToLocal(&uri_value)) { + return {}; + } + CHECK(uri_value->IsString()); + Utf8Value uri(env->isolate(), uri_value); + + Local properties_value; + if (!store + ->Get(env->context(), + FIXED_ONE_BYTE_STRING(env->isolate(), "properties")) + .ToLocal(&properties_value)) { + return {}; + } + std::string properties_storage; + std::optional properties; + if (properties_value->IsString()) { + Utf8Value properties_string(env->isolate(), properties_value); + std::string_view properties_view = properties_string.ToStringView(); + properties_storage.assign(properties_view.data(), properties_view.size()); + properties = std::string_view(properties_storage); + } else { + CHECK(properties_value->IsNullOrUndefined()); + } + + // CryptoStore is a global permission. URIs passed to STORE loaders can + // contain credentials, so they must not be exposed through permission + // errors or diagnostics. + THROW_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kCryptoStore, "", KeyObjectData()); + + std::optional> passphrase_content; + std::optional> passphrase; + if (IsAnyBufferSource(args[*offset + 3])) { + passphrase_content.emplace(args[*offset + 3]); + if (!passphrase_content->CheckSizeInt32()) [[unlikely]] { + THROW_ERR_OUT_OF_RANGE(env, "passphrase is too big"); + return {}; + } + passphrase = ncrypto::Buffer{ + .data = passphrase_content->data(), + .len = passphrase_content->size(), + }; + } else { + CHECK(args[*offset + 3]->IsNullOrUndefined()); + } + + *offset += 5; + EVPKeyPointer::StorePrivateKeyConfig config{ + .uri = uri.ToStringView(), + .properties = properties, + .passphrase = passphrase, + }; + auto res = EVPKeyPointer::TryLoadPrivateKeyFromStore(config); + if (res) { + return CreateAsymmetric(KeyType::kKeyTypePrivate, std::move(res.value)); + } + if (res.error.value() == EVPKeyPointer::PKParseError::NEED_PASSPHRASE) { + THROW_ERR_MISSING_PASSPHRASE(env, + "Passphrase required for encrypted key"); + } else { + ThrowCryptoError( + env, + res.openssl_error.value_or(0), + "Failed to load private key through an OpenSSL STORE loader"); + } + return {}; + } + + // Object formats: data is a JS Object (not buffer), format int determines + // whether this is a JWK or an OpenSSL STORE loader descriptor. if (args[*offset]->IsObject() && !IsAnyBufferSource(args[*offset]) && args[*offset + 1]->IsInt32()) { auto format = static_cast( @@ -818,7 +902,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. @@ -831,6 +917,15 @@ 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, + "URLs for OpenSSL STORE loaders are only accepted for private keys"); + return {}; + } } if (args[*offset]->IsString() || IsAnyBufferSource(args[*offset])) { @@ -1475,8 +1570,9 @@ void KeyObjectHandle::ExportECPublicRaw( } ECKeyPointer ec_key(m_pkey); - if (!ec_key) { - return THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + if (!ec_key || ec_key.getPublicKey() == nullptr) { + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC public key"); } CHECK(args[0]->IsInt32()); @@ -1509,13 +1605,14 @@ void KeyObjectHandle::ExportECPrivateRaw( ECKeyPointer ec_key(m_pkey); if (!ec_key) { - return THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); + return THROW_ERR_CRYPTO_OPERATION_FAILED(env, + "Failed to export EC private key"); } const BIGNUM* private_key = ec_key.getPrivateKey(); if (private_key == nullptr) { return THROW_ERR_CRYPTO_OPERATION_FAILED(env, - "Failed to get EC private key"); + "Failed to export EC private key"); } const auto group = ec_key.getGroup(); @@ -1575,6 +1672,8 @@ void KeyObjectHandle::ExportJWK( if (ExportJWKInner(env, key->Data(), args[0], args[1]->IsTrue())) { args.GetReturnValue().Set(args[0]); + } else if (!env->isolate()->HasPendingException()) { + THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to export JWK"); } } @@ -2046,6 +2145,8 @@ void Initialize(Environment* env, Local target) { static_cast(EVPKeyPointer::PKFormatType::RAW_PRIVATE); constexpr int kKeyFormatRawSeed = static_cast(EVPKeyPointer::PKFormatType::RAW_SEED); + constexpr int kKeyFormatStore = + static_cast(EVPKeyPointer::PKFormatType::STORE); constexpr auto kSigEncDER = DSASigEnc::DER; constexpr auto kSigEncP1363 = DSASigEnc::P1363; @@ -2092,6 +2193,7 @@ void Initialize(Environment* env, Local target) { NODE_DEFINE_CONSTANT(target, kKeyFormatRawPublic); NODE_DEFINE_CONSTANT(target, kKeyFormatRawPrivate); NODE_DEFINE_CONSTANT(target, kKeyFormatRawSeed); + NODE_DEFINE_CONSTANT(target, kKeyFormatStore); NODE_DEFINE_CONSTANT(target, kKeyTypeSecret); NODE_DEFINE_CONSTANT(target, kKeyTypePublic); NODE_DEFINE_CONSTANT(target, kKeyTypePrivate); 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/src/crypto/crypto_rsa.cc b/src/crypto/crypto_rsa.cc index ad27108418353f..430164f1526277 100644 --- a/src/crypto/crypto_rsa.cc +++ b/src/crypto/crypto_rsa.cc @@ -10,6 +10,10 @@ #include "v8.h" #include +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) +#include +#endif +#include #include namespace node { @@ -306,6 +310,10 @@ bool ExportJWKRsaKey(Environment* env, auto pub_key = rsa.getPublicKey(); + if (pub_key.n == nullptr || pub_key.e == nullptr) { + return false; + } + if (SetEncodedValue(env, target, env->jwk_n_string(), pub_key.n) .IsNothing() || SetEncodedValue(env, target, env->jwk_e_string(), pub_key.e) @@ -315,6 +323,12 @@ bool ExportJWKRsaKey(Environment* env, if (key.GetKeyType() == kKeyTypePrivate) { auto pvt_key = rsa.getPrivateKey(); + if (pub_key.d == nullptr || pvt_key.p == nullptr || pvt_key.q == nullptr || + pvt_key.dp == nullptr || pvt_key.dq == nullptr || + pvt_key.qi == nullptr) { + return false; + } + if (SetEncodedValue(env, target, env->jwk_d_string(), pub_key.d) .IsNothing() || SetEncodedValue(env, target, env->jwk_p_string(), pvt_key.p) @@ -334,6 +348,38 @@ bool ExportJWKRsaKey(Environment* env, return true; } +static bool SetRsaKeyDetail(Environment* env, + Local target, + const BIGNUM* n, + const BIGNUM* e) { + if (n == nullptr || e == nullptr) return false; + + if (target + ->Set(env->context(), + env->modulus_length_string(), + Number::New(env->isolate(), + static_cast(BignumPointer::GetBitCount(n)))) + .IsNothing()) { + return false; + } + + auto public_exponent = ArrayBuffer::NewBackingStore( + env->isolate(), + BignumPointer::GetByteCount(e), + BackingStoreInitializationMode::kUninitialized); + CHECK_EQ(BignumPointer::EncodePaddedInto( + e, + static_cast(public_exponent->Data()), + public_exponent->ByteLength()), + public_exponent->ByteLength()); + + return target + ->Set(env->context(), + env->public_exponent_string(), + ArrayBuffer::New(env->isolate(), std::move(public_exponent))) + .IsJust(); +} + KeyObjectData ImportJWKRsaKey(Environment* env, Local jwk) { Local n_value; Local e_value; @@ -449,35 +495,28 @@ bool GetRsaKeyDetail(Environment* env, // TODO(tniessen): Remove the "else" branch once we drop support for OpenSSL // versions older than 1.1.1e via FIPS / dynamic linking. const ncrypto::Rsa rsa = m_pkey; - if (!rsa) return false; - - auto pub_key = rsa.getPublicKey(); - - if (target - ->Set(env->context(), - env->modulus_length_string(), - Number::New( - env->isolate(), - static_cast(BignumPointer::GetBitCount(pub_key.n)))) - .IsNothing()) { + if (!rsa) { +#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL) + BIGNUM* n = nullptr; + BIGNUM* e = nullptr; + BignumPointer n_ptr; + BignumPointer e_ptr; + if (EVP_PKEY_get_bn_param(m_pkey.get(), OSSL_PKEY_PARAM_RSA_N, &n) == 1) { + n_ptr.reset(n); + } + if (EVP_PKEY_get_bn_param(m_pkey.get(), OSSL_PKEY_PARAM_RSA_E, &e) == 1) { + e_ptr.reset(e); + } + if (n_ptr && e_ptr) { + return SetRsaKeyDetail(env, target, n_ptr.get(), e_ptr.get()); + } +#endif return false; } - auto public_exponent = ArrayBuffer::NewBackingStore( - env->isolate(), - BignumPointer::GetByteCount(pub_key.e), - BackingStoreInitializationMode::kUninitialized); - CHECK_EQ(BignumPointer::EncodePaddedInto( - pub_key.e, - static_cast(public_exponent->Data()), - public_exponent->ByteLength()), - public_exponent->ByteLength()); + auto pub_key = rsa.getPublicKey(); - if (target - ->Set(env->context(), - env->public_exponent_string(), - ArrayBuffer::New(env->isolate(), std::move(public_exponent))) - .IsNothing()) { + if (!SetRsaKeyDetail(env, target, pub_key.n, pub_key.e)) { return false; } diff --git a/src/crypto/crypto_sig.cc b/src/crypto/crypto_sig.cc index de9e62a2a1f13c..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; @@ -390,6 +391,94 @@ bool SupportsContextString(const EVPKeyPointer& key) { #endif return false; } + +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, + const EVPKeyPointer& key, + const Digest& digest, + const ByteSource& input, + int padding, + std::optional salt_length, + DSASigEnc dsa_encoding) { + EVPMDCtxPointer context = EVPMDCtxPointer::New(); + if (!context || !context.digestInit(digest) || !context.digestUpdate(input)) + [[unlikely]] { + return {}; + } + + auto data = context.digestFinal(context.getExpectedSize()); + if (!data) [[unlikely]] { + return {}; + } + + EVPKeyCtxPointer pkctx = key.newCtx(); + if (!pkctx || pkctx.initForSign() <= 0 || + !ApplyRSAOptions(key, pkctx.get(), padding, salt_length) || + !pkctx.setSignatureMd(context)) [[unlikely]] { + return {}; + } + + auto signature = pkctx.sign(data); + if (!signature) [[unlikely]] { + return {}; + } + + DCHECK(!signature.isSecure()); + auto out = ByteSource::Allocated(signature.release()); + if (UseP1363Encoding(key, dsa_encoding)) { + return ConvertSignatureToP1363(env, key, std::move(out)); + } + return out; +} + +bool VerifyPrehashed(const EVPKeyPointer& key, + const Digest& digest, + const ByteSource& input, + const ByteSource& signature, + int padding, + std::optional salt_length) { + EVPMDCtxPointer context = EVPMDCtxPointer::New(); + if (!context || !context.digestInit(digest) || !context.digestUpdate(input)) + [[unlikely]] { + return false; + } + + auto data = context.digestFinal(context.getExpectedSize()); + if (!data) [[unlikely]] { + return false; + } + + EVPKeyCtxPointer pkctx = key.newCtx(); + return pkctx && pkctx.initForVerify() > 0 && + ApplyRSAOptions(key, pkctx.get(), padding, salt_length) && + pkctx.setSignatureMd(context) && pkctx.verify(signature, data); +} } // namespace SignBase::Error SignBase::Init(const char* digest) { @@ -812,9 +901,6 @@ bool SignTraits::DeriveBits(Environment* env, ByteSource* out, CryptoJobMode mode, CryptoErrorStore* errors) { - auto context = EVPMDCtxPointer::New(); - if (!context) [[unlikely]] - return false; const auto& key = params.key.GetAsymmetricKey(); bool has_context = (params.flags & SignConfiguration::kHasContextString && @@ -826,6 +912,21 @@ bool SignTraits::DeriveBits(Environment* env, return false; } + int padding = params.flags & SignConfiguration::kHasPadding + ? params.padding + : key.getDefaultSignPadding(); + + std::optional salt_length = + params.flags & SignConfiguration::kHasSaltLength + ? std::optional(params.salt_length) + : std::nullopt; + bool can_fallback_to_prehash = + CanUsePrehashedFallback(key, params.digest, has_context); + + auto context = EVPMDCtxPointer::New(); + if (!context) [[unlikely]] + return false; + auto ctx = ([&] { if (has_context) { ncrypto::Buffer context_buf{ @@ -854,15 +955,6 @@ bool SignTraits::DeriveBits(Environment* env, return false; } - int padding = params.flags & SignConfiguration::kHasPadding - ? params.padding - : key.getDefaultSignPadding(); - - std::optional salt_length = - params.flags & SignConfiguration::kHasSaltLength - ? std::optional(params.salt_length) - : std::nullopt; - if (!ApplyRSAOptions(key, *ctx, padding, salt_length)) { return false; } @@ -878,6 +970,16 @@ bool SignTraits::DeriveBits(Environment* env, *out = ByteSource::Allocated(data.release()); } else { auto data = context.sign(params.data); + if (!data && can_fallback_to_prehash) { + *out = SignPrehashed(env, + key, + params.digest, + params.data, + padding, + salt_length, + params.dsa_encoding); + return static_cast(*out); + } if (!data) [[unlikely]] { return false; } @@ -895,9 +997,18 @@ bool SignTraits::DeriveBits(Environment* env, case SignConfiguration::Mode::Verify: { auto buf = DataPointer::Alloc(1); static_cast(buf.get())[0] = 0; - if (context.verify(params.data, params.signature) && + int verify_result = context.verifyOneShot(params.data, params.signature); + if (verify_result == 1 && !HasSmallOrderEdDsaPoint(key, params.signature)) { static_cast(buf.get())[0] = 1; + } else if (verify_result < 0 && can_fallback_to_prehash && + VerifyPrehashed(key, + params.digest, + params.data, + params.signature, + padding, + salt_length)) { + static_cast(buf.get())[0] = 1; } *out = ByteSource::Allocated(buf.release()); } diff --git a/src/env.cc b/src/env.cc index d6a859e7a35452..476463741248cf 100644 --- a/src/env.cc +++ b/src/env.cc @@ -939,6 +939,10 @@ Environment::Environment(IsolateData* isolate_data, if (!options_->allow_ffi) { permission()->Apply(this, {"*"}, permission::PermissionScope::kFFI); } + if (!options_->allow_crypto_store) { + permission()->Apply( + this, {"*"}, permission::PermissionScope::kCryptoStore); + } if (!options_->allow_worker_threads) { permission()->Apply( this, {"*"}, permission::PermissionScope::kWorkerThreads); diff --git a/src/node_options.cc b/src/node_options.cc index ca12586e479b99..89638d03988059 100644 --- a/src/node_options.cc +++ b/src/node_options.cc @@ -730,6 +730,13 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { kAllowedInEnvvar, false, OptionNamespaces::kPermissionNamespace); + AddOption("--allow-crypto-store", + "allow loading private keys through OpenSSL STORE loaders when any " + "permissions are set", + &EnvironmentOptions::allow_crypto_store, + kAllowedInEnvvar, + false, + OptionNamespaces::kPermissionNamespace); AddOption("--experimental-repl-await", "experimental await keyword support in REPL", &EnvironmentOptions::experimental_repl_await, diff --git a/src/node_options.h b/src/node_options.h index eb5aadc2347e25..a87ccab303d70b 100644 --- a/src/node_options.h +++ b/src/node_options.h @@ -153,6 +153,7 @@ class EnvironmentOptions : public Options { bool allow_net = false; bool allow_wasi = false; bool allow_ffi = false; + bool allow_crypto_store = false; bool allow_worker_threads = false; bool experimental_repl_await = true; bool experimental_vm_modules = EXPERIMENTALS_DEFAULT_VALUE; diff --git a/src/permission/crypto_store_permission.cc b/src/permission/crypto_store_permission.cc new file mode 100644 index 00000000000000..52de6bcb27220e --- /dev/null +++ b/src/permission/crypto_store_permission.cc @@ -0,0 +1,31 @@ +#include "permission/crypto_store_permission.h" + +#include +#include + +namespace node { + +namespace permission { + +// CryptoStorePermission manages a single global deny state for loading +// private keys through OpenSSL STORE loaders. +void CryptoStorePermission::Apply(Environment* env, + const std::vector& allow, + PermissionScope scope) { + deny_all_ = true; +} + +void CryptoStorePermission::Drop(Environment* env, + PermissionScope scope, + const std::string_view& param) { + deny_all_ = true; +} + +bool CryptoStorePermission::is_granted(Environment* env, + PermissionScope perm, + const std::string_view& param) const { + return perm != PermissionScope::kCryptoStore || !deny_all_; +} + +} // namespace permission +} // namespace node diff --git a/src/permission/crypto_store_permission.h b/src/permission/crypto_store_permission.h new file mode 100644 index 00000000000000..ff538b493abf10 --- /dev/null +++ b/src/permission/crypto_store_permission.h @@ -0,0 +1,34 @@ +#ifndef SRC_PERMISSION_CRYPTO_STORE_PERMISSION_H_ +#define SRC_PERMISSION_CRYPTO_STORE_PERMISSION_H_ + +#if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS + +#include +#include "permission/permission_base.h" + +namespace node { + +namespace permission { + +class CryptoStorePermission final : public PermissionBase { + public: + void Apply(Environment* env, + const std::vector& allow, + PermissionScope scope) override; + void Drop(Environment* env, + PermissionScope scope, + const std::string_view& param = "") override; + bool is_granted(Environment* env, + PermissionScope perm, + const std::string_view& param = "") const override; + + private: + bool deny_all_ = false; +}; + +} // namespace permission + +} // namespace node + +#endif // defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#endif // SRC_PERMISSION_CRYPTO_STORE_PERMISSION_H_ diff --git a/src/permission/permission.cc b/src/permission/permission.cc index c593502d94eb49..c6dc35b786057c 100644 --- a/src/permission/permission.cc +++ b/src/permission/permission.cc @@ -48,6 +48,8 @@ constexpr std::string_view GetDiagnosticsChannelName(PermissionScope scope) { return "node:permission-model:addon"; case PermissionScope::kFFI: return "node:permission-model:ffi"; + case PermissionScope::kCryptoStore: + return "node:permission-model:crypto-store"; default: return {}; } @@ -131,6 +133,8 @@ Permission::Permission() : enabled_(false), warning_only_(false) { std::shared_ptr net = std::make_shared(); std::shared_ptr addon = std::make_shared(); std::shared_ptr ffi = std::make_shared(); + std::shared_ptr crypto_store = + std::make_shared(); #define V(Name, _, __, ___) \ nodes_.insert(std::make_pair(PermissionScope::k##Name, fs)); FILESYSTEM_PERMISSIONS(V) @@ -163,6 +167,10 @@ Permission::Permission() : enabled_(false), warning_only_(false) { nodes_.insert(std::make_pair(PermissionScope::k##Name, ffi)); FFI_PERMISSIONS(V) #undef V +#define V(Name, _, __, ___) \ + nodes_.insert(std::make_pair(PermissionScope::k##Name, crypto_store)); + CRYPTO_STORE_PERMISSIONS(V) +#undef V } const char* GetErrorFlagSuggestion(node::permission::PermissionScope perm) { diff --git a/src/permission/permission.h b/src/permission/permission.h index 84e3ea67ed5e04..7ebe1c6748d8d2 100644 --- a/src/permission/permission.h +++ b/src/permission/permission.h @@ -8,6 +8,7 @@ #include "node_options.h" #include "permission/addon_permission.h" #include "permission/child_process_permission.h" +#include "permission/crypto_store_permission.h" #include "permission/ffi_permission.h" #include "permission/fs_permission.h" #include "permission/inspector_permission.h" diff --git a/src/permission/permission_base.h b/src/permission/permission_base.h index 11f4d6c6bfa65f..7fadaf8bb98cb6 100644 --- a/src/permission/permission_base.h +++ b/src/permission/permission_base.h @@ -37,6 +37,9 @@ namespace permission { #define FFI_PERMISSIONS(V) V(FFI, "ffi", PermissionsRoot, "--allow-ffi") +#define CRYPTO_STORE_PERMISSIONS(V) \ + V(CryptoStore, "crypto.store", PermissionsRoot, "--allow-crypto-store") + #define PERMISSIONS(V) \ FILESYSTEM_PERMISSIONS(V) \ CHILD_PROCESS_PERMISSIONS(V) \ @@ -45,7 +48,8 @@ namespace permission { INSPECTOR_PERMISSIONS(V) \ NET_PERMISSIONS(V) \ ADDON_PERMISSIONS(V) \ - FFI_PERMISSIONS(V) + FFI_PERMISSIONS(V) \ + CRYPTO_STORE_PERMISSIONS(V) #define V(name, _, __, ___) k##name, enum class PermissionScope { diff --git a/test/parallel/test-crypto-key-store-pkcs11.js b/test/parallel/test-crypto-key-store-pkcs11.js new file mode 100644 index 00000000000000..80706e135a92ac --- /dev/null +++ b/test/parallel/test-crypto-key-store-pkcs11.js @@ -0,0 +1,699 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const { hasOpenSSL } = require('../common/crypto'); +if (!hasOpenSSL(3, 0)) + common.skip('requires OpenSSL 3.x'); + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { + constants: { + RSA_PKCS1_PSS_PADDING, + }, + createPublicKey, + createPrivateKey, + createSign, + sign, + verify, +} = require('crypto'); +const tmpdir = require('../common/tmpdir'); + +const { subtle } = globalThis.crypto; +const kData = Buffer.from( + Array.from({ length: 256 }, (_, i) => (i * 17 + 43) & 0xff)); +const kProperties = 'provider=pkcs11'; +const kOpenSSLConfig = process.env.NODE_TEST_PKCS11_OPENSSL_CONF; +const kPin = process.env.NODE_TEST_PKCS11_PIN; +const kExpectedPrivateExportFailure = + /Failed to encode private key|Failed to export JWK|Failed to export EC .* key|Failed to get raw .* key|keymgmt export failure|not exportable|operation not supported|not supported|incompatible/i; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + encoding: 'utf8', + ...options, + }); + + if (result.status !== 0) { + const output = [ + result.error?.message, + result.stdout, + result.stderr, + ].filter(Boolean).join('\n'); + assert.fail(`${command} ${args.join(' ')} failed\n${output}`); + } + + return result.stdout.trim(); +} + +function commandExists(command) { + const result = spawnSync(command, ['--version'], { stdio: 'ignore' }); + return result.status === 0; +} + +function findNixBuild() { + return [ + 'nix-build', + '/nix/var/nix/profiles/default/bin/nix-build', + '/run/current-system/sw/bin/nix-build', + ].find(commandExists); +} + +function lastStorePath(output) { + const lines = output.split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].startsWith('/nix/store/')) return lines[i]; + } +} + +function nixBuild(command, repo, expression) { + const output = run(command, [ + '-I', + `nixpkgs=${path.join(repo, 'tools/nix/pkgs.nix')}`, + '-I', + `node=${repo}`, + '--no-out-link', + '-E', + expression, + ]); + const storePath = lastStorePath(output); + assert(storePath, `nix-build did not print a store path:\n${output}`); + return storePath; +} + +function findFile(root, suffixes) { + const stack = [root]; + while (stack.length > 0) { + const dir = stack.pop(); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(file); + } else if (suffixes.some((suffix) => file.endsWith(suffix))) { + return file; + } + } + } +} + +function shouldCreateNixFixture() { + // Enabled explicitly by the shared OpenSSL CI matrix. + return process.env.NODE_TEST_PKCS11_NIX === '1'; +} + +function createNixFixture() { + if (!shouldCreateNixFixture()) { + common.skip('requires a configured PKCS#11 provider test fixture'); + } + const nixBuildCommand = findNixBuild(); + if (nixBuildCommand === undefined) { + assert.fail('requires nix-build for the PKCS#11 provider test fixture'); + } + + const repo = process.env.TAR_DIR || path.resolve(__dirname, '..', '..'); + const opensslExpression = ` + let + pkgs = import {}; + openssl = (import { + inherit pkgs; + }).openssl; + in + `; + const pkcs11ProviderPackage = nixBuild(nixBuildCommand, repo, ` + ${opensslExpression} + (pkgs.pkcs11-provider.override { inherit openssl; }) + .overrideAttrs (_: { doCheck = false; }) + `); + const softhsmPackage = nixBuild(nixBuildCommand, repo, ` + ${opensslExpression} + (pkgs.softhsm.override { inherit openssl; }) + .overrideAttrs (_: { doCheck = false; }) + `); + const openscPackage = nixBuild( + nixBuildCommand, + repo, + 'let pkgs = import {}; in pkgs.opensc'); + + const pkcs11ProviderModule = findFile(pkcs11ProviderPackage, [ + '/lib/ossl-modules/pkcs11.so', + '/lib/ossl-modules/pkcs11.dylib', + ]); + const softhsmModule = findFile(softhsmPackage, [ + '/lib/softhsm/libsofthsm2.so', + '/lib/softhsm/libsofthsm2.dylib', + ]); + const softhsm2Util = path.join(softhsmPackage, 'bin/softhsm2-util'); + const pkcs11Tool = path.join(openscPackage, 'bin/pkcs11-tool'); + for (const file of [ + pkcs11ProviderModule, + softhsmModule, + softhsm2Util, + pkcs11Tool, + ]) { + assert(file && fs.existsSync(file), `missing PKCS#11 fixture file: ${file}`); + } + + tmpdir.refresh(); + const work = tmpdir.resolve('pkcs11-store'); + const tokens = path.join(work, 'tokens'); + fs.mkdirSync(tokens, { recursive: true }); + + const pin = '1234'; + const softhsmConf = path.join(work, 'softhsm2.conf'); + const opensslConf = path.join(work, 'openssl-pkcs11.cnf'); + fs.writeFileSync(softhsmConf, ` +directories.tokendir = ${tokens} +objectstore.backend = file +log.level = ERROR +slots.removable = false +`); + fs.writeFileSync(opensslConf, ` +nodejs_conf = nodejs_init + +[nodejs_init] +providers = provider_sect + +[provider_sect] +default = default_sect +pkcs11 = pkcs11_sect + +[default_sect] +activate = 1 + +[pkcs11_sect] +module = ${pkcs11ProviderModule} +pkcs11-module-path = ${softhsmModule} +pkcs11-module-quirks = no-deinit +activate = 1 +`); + + const env = { ...process.env, SOFTHSM2_CONF: softhsmConf }; + run(softhsm2Util, [ + '--init-token', + '--free', + '--label', + 'node-test', + '--pin', + pin, + '--so-pin', + 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'], + ['EC:ED25519', '03', 'node-ed25519'], + ['EC:ED448', '04', 'node-ed448'], + ]) { + run(pkcs11Tool, [ + '--module', + softhsmModule, + '--login', + '--pin', + pin, + '--keypairgen', + '--key-type', + keyType, + '--id', + id, + '--label', + label, + '--usage-sign', + ], { env }); + } + + run(pkcs11Tool, [ + '--module', + softhsmModule, + '--login', + '--pin', + pin, + '--keypairgen', + '--key-type', + 'EC:prime256v1', + '--id', + '05', + '--label', + 'node-ecdh', + '--usage-derive', + ], { env }); + + return { opensslConf, pin, softhsmConf }; +} + +function getFixture() { + if (process.env.NODE_TEST_PKCS11_OPENSSL_CONF && + process.env.NODE_TEST_PKCS11_PIN) { + return { + opensslConf: process.env.NODE_TEST_PKCS11_OPENSSL_CONF, + pin: process.env.NODE_TEST_PKCS11_PIN, + softhsmConf: process.env.SOFTHSM2_CONF, + }; + } + + return createNixFixture(); +} + +function runInChild() { + const fixture = getFixture(); + const child = spawnSync(process.execPath, [ + `--openssl-config=${fixture.opensslConf}`, + __filename, + ], { + env: { + ...process.env, + NODE_TEST_PKCS11_CHILD: '1', + NODE_TEST_PKCS11_OPENSSL_CONF: fixture.opensslConf, + NODE_TEST_PKCS11_PIN: fixture.pin, + ...(fixture.softhsmConf && { SOFTHSM2_CONF: fixture.softhsmConf }), + }, + stdio: 'inherit', + }); + assert.strictEqual(child.status, 0); +} + +function privateKeyUrl(label) { + return new URL(`pkcs11:object=${label};type=private`); +} + +function loadPrivateKey(label) { + return createPrivateKey({ + key: privateKeyUrl(label), + passphrase: kPin, + properties: kProperties, + }); +} + +function assertKeyDetails(key, type, asymmetricKeyType) { + assert.strictEqual(key.type, type); + assert.strictEqual(key.asymmetricKeyType, asymmetricKeyType); + + switch (asymmetricKeyType) { + case 'rsa': + assert.strictEqual(key.asymmetricKeyDetails.modulusLength, 2048); + assert.strictEqual(key.asymmetricKeyDetails.publicExponent, 65537n); + break; + case 'ec': + assert.strictEqual(key.asymmetricKeyDetails.namedCurve, 'prime256v1'); + break; + case 'ed25519': + case 'ed448': + assert.deepStrictEqual(key.asymmetricKeyDetails, {}); + break; + default: + assert.fail(`unexpected asymmetric key type ${asymmetricKeyType}`); + } +} + +function assertDerivedPublicKey(privateKey, asymmetricKeyType) { + const publicKey = createPublicKey(privateKey); + assertKeyDetails(publicKey, 'public', asymmetricKeyType); + return publicKey; +} + +function assertPublicExports(publicKey) { + const spkiPem = publicKey.export({ format: 'pem', type: 'spki' }); + assert.strictEqual( + spkiPem.split('\n')[0], + '-----BEGIN PUBLIC KEY-----'); + + const spkiDer = publicKey.export({ format: 'der', type: 'spki' }); + assert(Buffer.isBuffer(spkiDer)); + assert(spkiDer.byteLength > 0); +} + +function assertPrivateExportsRejected(privateKey, asymmetricKeyType) { + const specs = [ + { format: 'pem', type: 'pkcs8' }, + { format: 'der', type: 'pkcs8' }, + { format: 'jwk' }, + ]; + + switch (asymmetricKeyType) { + case 'rsa': + specs.push( + { format: 'pem', type: 'pkcs1' }, + { format: 'der', type: 'pkcs1' }); + break; + case 'ec': + specs.push( + { format: 'pem', type: 'sec1' }, + { format: 'der', type: 'sec1' }, + { format: 'raw-private' }); + break; + default: + specs.push({ format: 'raw-private' }); + } + + for (const options of specs) { + assert.throws(() => { + privateKey.export(options); + }, { + message: kExpectedPrivateExportFailure, + }); + } +} + +function assertOneShotSignVerify(digest, data, privateKey, options = {}) { + const publicKey = createPublicKey(privateKey); + const signKey = { key: privateKey, ...options }; + const verifyPublicKey = { key: publicKey, ...options }; + const verifyPrivateKey = { key: privateKey, ...options }; + + const signature = sign(digest, data, signKey); + assert(signature.byteLength > 0); + assert.strictEqual(verify(digest, data, verifyPublicKey, signature), true); + assert.strictEqual(verify(digest, data, verifyPrivateKey, signature), true); + + return signature; +} + +function assertStreamingSignOneShotVerify(digest, data, privateKey) { + const publicKey = createPublicKey(privateKey); + const signature = createSign(digest).update(data).sign(privateKey); + assert(signature.byteLength > 0); + assert.strictEqual(verify(digest, data, publicKey, signature), true); + assert.strictEqual(verify(digest, data, privateKey, signature), true); +} + +async function assertWebCryptoSignVerify( + privateKey, + publicKey, + algorithm, + privateUsages, + publicUsages, + 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.exportKey('pkcs8', privateCryptoKey), + { + name: 'InvalidAccessError', + message: /not extractable/i, + }); + + const publicCryptoKey = publicKey.toCryptoKey( + algorithm, + true, + publicUsages); + assert.strictEqual(publicCryptoKey.type, 'public'); + assert.strictEqual(publicCryptoKey.extractable, true); + assert.deepStrictEqual(publicCryptoKey.usages, publicUsages); + + const signature = await subtle.sign( + signAlgorithm, + privateCryptoKey, + kData); + assert(signature instanceof ArrayBuffer); + assert(signature.byteLength > 0); + assert.strictEqual( + await subtle.verify( + signAlgorithm, + publicCryptoKey, + signature, + kData), + true); + + try { + const exportedPublicKey = await subtle.exportKey('spki', publicCryptoKey); + assert(exportedPublicKey instanceof ArrayBuffer); + assert(exportedPublicKey.byteLength > 0); + } catch (err) { + assert.strictEqual(err.name, 'OperationError'); + assert.match(err.message, /operation-specific reason|not supported/i); + } +} + +async function assertPrivateCryptoKeyExportsRejected( + privateKey, + algorithm, + privateUsages, +) { + const privateCryptoKey = privateKey.toCryptoKey( + algorithm, + true, + privateUsages); + assert.strictEqual(privateCryptoKey.type, 'private'); + assert.strictEqual(privateCryptoKey.extractable, true); + assert.deepStrictEqual(privateCryptoKey.usages, privateUsages); + + for (const format of ['pkcs8', 'jwk']) { + await assert.rejects( + subtle.exportKey(format, privateCryptoKey), + (err) => { + assert(err.name === 'OperationError' || + err.code === 'ERR_CRYPTO_OPERATION_FAILED'); + assert.match(err.cause?.message ?? err.message, + kExpectedPrivateExportFailure); + return true; + }); + } +} + +function assertStoreOptions() { + assert.strictEqual( + createPrivateKey({ + key: privateKeyUrl('node-rsa'), + passphrase: kPin, + }).asymmetricKeyType, + 'rsa'); + + assert.strictEqual( + createPrivateKey({ + key: privateKeyUrl('node-rsa'), + passphrase: kPin, + properties: kProperties, + }).asymmetricKeyType, + 'rsa'); +} + +function assertChild(args, expectedStatus, stderrPattern) { + const child = spawnSync(process.execPath, args, { + env: process.env, + encoding: 'utf8', + }); + assert.strictEqual(child.signal, null); + assert.strictEqual(child.status, expectedStatus, child.stderr || child.stdout); + if (stderrPattern) assert.match(child.stderr, stderrPattern); +} + +function assertStoreLoadFailure(code, stderrPattern) { + assertChild([ + `--openssl-config=${kOpenSSLConfig}`, + '-e', + code, + ], 1, stderrPattern); +} + +function assertPassphraseHandling() { + assertStoreLoadFailure(` + require('crypto').createPrivateKey({ + key: new URL('pkcs11:object=node-rsa;type=private'), + properties: ${JSON.stringify(kProperties)}, + }); + `, /ERR_MISSING_PASSPHRASE/); + + assertStoreLoadFailure(` + require('crypto').createPrivateKey({ + key: new URL('pkcs11:object=node-rsa;type=private'), + passphrase: 'bad', + properties: ${JSON.stringify(kProperties)}, + }); + `, /Failed to load private key through an OpenSSL STORE loader/); +} + +function assertBadProperties() { + assertStoreLoadFailure(` + require('crypto').createPrivateKey({ + key: new URL('pkcs11:object=node-rsa;type=private'), + passphrase: ${JSON.stringify(kPin)}, + properties: 'provider=default', + }); + `, /Failed to load private key through an OpenSSL STORE loader|No such file or directory|unsupported/i); +} + +function assertPermissionModel() { + const code = ` + require('crypto').createPrivateKey({ + key: new URL('pkcs11:object=node-rsa;type=private'), + passphrase: ${JSON.stringify(kPin)}, + properties: ${JSON.stringify(kProperties)}, + }); + `; + + assertChild([ + `--openssl-config=${kOpenSSLConfig}`, + '--permission', + '--allow-fs-read=*', + '-e', + code, + ], 1, /ERR_ACCESS_DENIED/); + + assertChild([ + `--openssl-config=${kOpenSSLConfig}`, + '--permission', + '--allow-crypto-store', + '--allow-fs-read=*', + '-e', + code, + ], 0); +} + +function assertInlineSignWithStoreUrl(privateKey) { + const publicKey = createPublicKey(privateKey); + const signature = sign('sha256', kData, { + key: privateKeyUrl('node-rsa'), + passphrase: kPin, + properties: kProperties, + }); + assert(signature.byteLength > 0); + assert.strictEqual(verify('sha256', kData, publicKey, signature), true); +} + +async function testRsa() { + const privateKey = loadPrivateKey('node-rsa'); + assertKeyDetails(privateKey, 'private', 'rsa'); + + 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); + assertPrivateExportsRejected(privateKey, 'rsa'); + await assertPrivateCryptoKeyExportsRejected( + privateKey, + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, + ['sign']); + + await assertWebCryptoSignVerify( + privateKey, + publicKey, + { 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() { + const privateKey = loadPrivateKey('node-ec'); + assertKeyDetails(privateKey, 'private', 'ec'); + + const publicKey = assertDerivedPublicKey(privateKey, 'ec'); + assertOneShotSignVerify('sha256', kData, privateKey); + assertOneShotSignVerify('sha256', kData, privateKey, { + dsaEncoding: 'ieee-p1363', + }); + + assertPublicExports(publicKey); + assertPrivateExportsRejected(privateKey, 'ec'); + await assertPrivateCryptoKeyExportsRejected( + privateKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + ['sign']); + await assertWebCryptoSignVerify( + privateKey, + publicKey, + { name: 'ECDSA', namedCurve: 'P-256' }, + ['sign'], + ['verify'], + { name: 'ECDSA', hash: 'SHA-256' }); +} + +async function testEd25519() { + const privateKey = loadPrivateKey('node-ed25519'); + assertKeyDetails(privateKey, 'private', 'ed25519'); + + const publicKey = assertDerivedPublicKey(privateKey, 'ed25519'); + assertOneShotSignVerify(null, kData, privateKey); + assertPublicExports(publicKey); + assertPrivateExportsRejected(privateKey, 'ed25519'); + await assertPrivateCryptoKeyExportsRejected( + privateKey, + { name: 'Ed25519' }, + ['sign']); + + await assertWebCryptoSignVerify( + privateKey, + publicKey, + { name: 'Ed25519' }, + ['sign'], + ['verify']); +} + +function testEcDiffieHellmanExports() { + const privateKey = loadPrivateKey('node-ecdh'); + assertKeyDetails(privateKey, 'private', 'ec'); + + const publicKey = assertDerivedPublicKey(privateKey, 'ec'); + assertPublicExports(publicKey); + assertPrivateExportsRejected(privateKey, 'ec'); +} + +async function testEd448() { + const privateKey = loadPrivateKey('node-ed448'); + assertKeyDetails(privateKey, 'private', 'ed448'); + + const publicKey = assertDerivedPublicKey(privateKey, 'ed448'); + assertOneShotSignVerify(null, kData, privateKey); + assertPublicExports(publicKey); + assertPrivateExportsRejected(privateKey, 'ed448'); + await assertPrivateCryptoKeyExportsRejected( + privateKey, + { name: 'Ed448' }, + ['sign']); + + await assertWebCryptoSignVerify( + privateKey, + publicKey, + { name: 'Ed448' }, + ['sign'], + ['verify']); +} + +async function runTest() { + assertStoreOptions(); + assertPassphraseHandling(); + assertBadProperties(); + assertPermissionModel(); + + await testRsa(); + await testEc(); + testEcDiffieHellmanExports(); + await testEd25519(); + await testEd448(); +} + +if (process.env.NODE_TEST_PKCS11_CHILD === '1') { + runTest().then(common.mustCall()).catch((err) => { + process.nextTick(() => { + throw err; + }); + }); +} else { + runInChild(); +} diff --git a/test/parallel/test-crypto-key-store.js b/test/parallel/test-crypto-key-store.js new file mode 100644 index 00000000000000..f8efac888154f9 --- /dev/null +++ b/test/parallel/test-crypto-key-store.js @@ -0,0 +1,188 @@ +'use strict'; +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const { hasOpenSSL } = require('../common/crypto'); +if (!hasOpenSSL(3)) + common.skip('requires OpenSSL 3.x'); + +// Verifies that crypto.createPrivateKey() can pass a WHATWG URL (here a file: +// URI) to an OpenSSL STORE loader, and that the resulting KeyObject works for +// signing and verification. + +const assert = require('assert'); +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'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const data = Buffer.from('hello store'); + +{ + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const file = path.join(tmpdir.path, 'priv.pem'); + fs.writeFileSync(file, privateKey.export({ format: 'pem', type: 'pkcs8' })); + const url = pathToFileURL(file); + + const pk = createPrivateKey(url); + assert.strictEqual(pk.type, 'private'); + assert.strictEqual(pk.asymmetricKeyType, 'ed25519'); + + const sig = sign(null, data, pk); + assert.strictEqual(verify(null, data, publicKey, sig), true); + + const pkWithProperties = createPrivateKey({ key: url, properties: '' }); + assert.strictEqual(pkWithProperties.type, 'private'); + assert.strictEqual(pkWithProperties.asymmetricKeyType, 'ed25519'); + assert.strictEqual( + verify(null, data, publicKey, sign(null, data, pkWithProperties)), + true); + + // Passing the URL inline to sign() behaves like createPrivateKey(). + assert.strictEqual(verify(null, data, publicKey, sign(null, data, url)), true); + + assert.throws(() => createPrivateKey({ key: url, properties: 1 }), { + code: 'ERR_INVALID_ARG_TYPE', + }); +} + +{ + 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'); + const file = path.join(tmpdir.path, 'enc.pem'); + fs.writeFileSync(file, privateKey.export({ + format: 'pem', type: 'pkcs8', cipher: 'aes-256-cbc', passphrase: 'pw', + })); + const url = pathToFileURL(file); + + const sig = sign(null, data, { key: url, passphrase: Buffer.from('pw') }); + assert.strictEqual(verify(null, data, publicKey, sig), true); + + assert.throws(() => createPrivateKey(url), { + code: 'ERR_MISSING_PASSPHRASE', + }); + + assert.throws(() => createPrivateKey({ key: url, passphrase: 'bad' }), + common.expectsError({ + name: 'Error', + code: /^ERR_OSSL_/, + })); +} + +{ + // A URL is only accepted in private-key contexts. + const url = pathToFileURL(path.join(tmpdir.path, 'priv.pem')); + 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/, + }); +} + +{ + // A non-key URI fails to load. + const file = path.join(tmpdir.path, 'nope.pem'); + fs.writeFileSync(file, 'not a key'); + assert.throws(() => createPrivateKey(pathToFileURL(file)), { + name: 'Error', + }); +} diff --git a/test/parallel/test-crypto-sign-verify.js b/test/parallel/test-crypto-sign-verify.js index a6a0d339d2432d..527c39b30786aa 100644 --- a/test/parallel/test-crypto-sign-verify.js +++ b/test/parallel/test-crypto-sign-verify.js @@ -625,6 +625,41 @@ if (hasOpenSSL(3, 2)) { assert.throws(() => crypto.verify(null, data, 'test', input), errObj); }); +// Preserve the current behavior from https://github.com/nodejs/node/issues/53761: +// one-shot verify does not accept SM2 signatures produced by the streaming path. +if (hasOpenSSL(3) && crypto.getHashes().includes('sm3')) { + const data = Buffer.from('AABB'); + const privateKey = crypto.createPrivateKey(`-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqBHM9VAYItBG0wawIBAQQgbjCNHopgvyGVfLaP +PamI9E9lf6jXT+xm1Pns1t/xQTihRANCAATV+I7HUGF2gC+miVl3JfjpoZaU2hrZ +QqHwKUNtIDE/uxxWNLBbYKaiLOWrbYA8skrWQWl3RkbXW4ZI28afRw9g +-----END PRIVATE KEY----- +`); + const publicKey = crypto.createPublicKey(`-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoEcz1UBgi0DQgAE1fiOx1BhdoAvpolZdyX46aGWlNoa +2UKh8ClDbSAxP7scVjSwW2Cmoizlq22APLJK1kFpd0ZG11uGSNvGn0cPYA== +-----END PUBLIC KEY-----`); + // Generate the signatures in-test so this checks API behavior rather than + // provider-version-specific SM2 signature fixtures. + const validOneShotSignature = crypto.sign('sm3', data, privateKey); + const streamingSign = crypto.createSign('sm3'); + streamingSign.update(data); + const streamingOnlySignature = streamingSign.sign(privateKey); + + assert.strictEqual( + crypto.verify('sm3', data, publicKey, validOneShotSignature), + true); + assert.strictEqual( + crypto.verify('sm3', data, publicKey, streamingOnlySignature), + false); + + const streamingVerify = crypto.createVerify('sm3'); + streamingVerify.update(data); + assert.strictEqual( + streamingVerify.verify(publicKey, streamingOnlySignature), + true); +} + { const data = Buffer.from('Hello world'); const keys = [['ec-key.pem', 64], ['dsa_private_1025.pem', 40]]; @@ -734,13 +769,9 @@ if (hasOpenSSL(3, 2)) { // 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'); diff --git a/test/parallel/test-permission-crypto-store.js b/test/parallel/test-permission-crypto-store.js new file mode 100644 index 00000000000000..e74cdd9c1b0204 --- /dev/null +++ b/test/parallel/test-permission-crypto-store.js @@ -0,0 +1,91 @@ +// Flags: --permission --allow-fs-read=* --allow-fs-write=* --allow-crypto-store --allow-child-process +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); +const { hasOpenSSL3 } = require('../common/crypto'); +if (!hasOpenSSL3) + common.skip('requires OpenSSL 3.x'); + +// Verifies the crypto.store permission: allowed when --allow-crypto-store is +// set, can be dropped at runtime, and denied by default in a child process. + +const assert = require('assert'); +const dc = require('diagnostics_channel'); +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); +const { createPrivateKey, generateKeyPairSync } = require('crypto'); +const { spawnSync } = require('child_process'); +const tmpdir = require('../common/tmpdir'); +tmpdir.refresh(); + +const file = path.join(tmpdir.path, 'priv.pem'); +fs.writeFileSync( + file, generateKeyPairSync('ed25519').privateKey.export({ + format: 'pem', type: 'pkcs8', + })); +const url = pathToFileURL(file); + +assert.strictEqual(process.permission.has('crypto.store'), true); +assert.strictEqual(createPrivateKey(url).type, 'private'); +assert.throws(() => createPrivateKey({ + href: file, + protocol: 'pkcs11:', +}), { code: 'ERR_INVALID_URL' }); + +process.permission.drop('crypto.store'); +assert.strictEqual(process.permission.has('crypto.store'), false); + +const secret = 'store-permission-secret'; +const messages = []; +dc.subscribe('node:permission-model:crypto-store', (message) => { + messages.push(message); +}); +assert.throws( + () => createPrivateKey(new URL(`pkcs11:object=key;pin-value=${secret}`)), + (error) => { + assert.strictEqual(error.code, 'ERR_ACCESS_DENIED'); + assert.strictEqual(error.permission, 'CryptoStore'); + assert.strictEqual(error.resource, ''); + assert.doesNotMatch(error.stack, new RegExp(secret)); + return true; + }); +assert.strictEqual(messages.length, 1); +assert.strictEqual(messages[0].permission, 'CryptoStore'); +assert.strictEqual(messages[0].resource, ''); +assert.doesNotMatch(JSON.stringify(messages[0]), new RegExp(secret)); + +// Denied by default when the flag is not provided. +{ + const { status, stdout } = spawnSync(process.execPath, [ + '--permission', '--allow-fs-read=*', + '-e', `try { require('crypto').createPrivateKey(new URL(${JSON.stringify(url.href)})); console.log('LOADED'); } catch (e) { console.log(e.code, e.permission); }`, + ]); + assert.strictEqual(status, 0); + assert.match(stdout.toString(), /ERR_ACCESS_DENIED CryptoStore/); +} + +// crypto.store grants the STORE loader authority to access files even when +// fs.read is not granted. +{ + const { status, stdout } = spawnSync(process.execPath, [ + '--permission', '--allow-crypto-store', + '-e', `try { require('crypto').createPrivateKey(new URL(${JSON.stringify(url.href)})); console.log('LOADED'); } catch (e) { console.log(e.code, e.permission); }`, + ]); + assert.strictEqual(status, 0); + assert.match(stdout.toString(), /LOADED/); +} + +// OpenSSL tries the file loader before the loader identified by an opaque URI. +{ + const opaqueFile = 'pkcs11:priv.pem'; + fs.copyFileSync(file, path.join(tmpdir.path, opaqueFile)); + const { status, stdout } = spawnSync(process.execPath, [ + '--permission', '--allow-crypto-store', + '-e', `try { require('crypto').createPrivateKey(new URL(${JSON.stringify(opaqueFile)})); console.log('LOADED'); } catch (e) { console.log(e.code, e.permission); }`, + ], { cwd: tmpdir.path }); + assert.strictEqual(status, 0); + assert.match(stdout.toString(), /LOADED/); +} diff --git a/test/parallel/test-permission-has.js b/test/parallel/test-permission-has.js index 838599735610e2..42e86a35ffa530 100644 --- a/test/parallel/test-permission-has.js +++ b/test/parallel/test-permission-has.js @@ -30,6 +30,7 @@ const assert = require('assert'); assert.ok(!process.permission.has('fs')); assert.ok(process.permission.has('fs.read')); assert.ok(!process.permission.has('fs.write')); + assert.ok(!process.permission.has('crypto.store')); assert.ok(!process.permission.has('wasi')); assert.ok(!process.permission.has('worker')); assert.ok(!process.permission.has('inspector')); diff --git a/test/parallel/test-permission-warning-flags.js b/test/parallel/test-permission-warning-flags.js index d90bdadf78771e..3355421d61bd5c 100644 --- a/test/parallel/test-permission-warning-flags.js +++ b/test/parallel/test-permission-warning-flags.js @@ -7,6 +7,7 @@ const assert = require('assert'); const warnFlags = [ '--allow-addons', '--allow-child-process', + '--allow-crypto-store', '--allow-inspector', '--allow-wasi', '--allow-worker', diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts index d91c5018ba688a..e2028c7d25fcaf 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -9,16 +9,22 @@ declare namespace InternalCryptoBinding { type KeyFormatRawPublic = 3; type KeyFormatRawPrivate = 4; type KeyFormatRawSeed = 5; + type KeyFormatStore = 6; type PublicKeyFormat = KeyFormatDER | KeyFormatPEM | KeyFormatJWK | KeyFormatRawPublic | undefined; type PrivateKeyFormat = KeyFormatDER | KeyFormatPEM | KeyFormatJWK | - KeyFormatRawPrivate | KeyFormatRawSeed | undefined; + KeyFormatRawPrivate | KeyFormatRawSeed | KeyFormatStore | undefined; type KeyFormat = PublicKeyFormat | PrivateKeyFormat; type KeyEncoding = string | number | null | undefined; type KeyPassphrase = ByteSource | null | undefined; type NamedCurve = string | null | undefined; - type PreparedAsymmetricKeyData = KeyObjectHandle | ByteSource | JwkKey; + interface StorePrivateKeyData { + uri: string; + properties: string | null; + } + type PreparedAsymmetricKeyData = + KeyObjectHandle | ByteSource | JwkKey | StorePrivateKeyData; type PreparedSecretKeyData = KeyObjectHandle | ByteSource; type CryptoJobAsyncMode = 0; type CryptoJobSyncMode = 1; @@ -862,6 +868,7 @@ export interface CryptoBinding { kKeyFormatRawPrivate: InternalCryptoBinding.KeyFormatRawPrivate; kKeyFormatRawPublic: InternalCryptoBinding.KeyFormatRawPublic; kKeyFormatRawSeed: InternalCryptoBinding.KeyFormatRawSeed; + kKeyFormatStore: InternalCryptoBinding.KeyFormatStore; kKeyTypePrivate: number; kKeyTypePublic: number; kKeyTypeSecret: number;