Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/build-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}" \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test-shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
175 changes: 171 additions & 4 deletions deps/ncrypto/ncrypto.cc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <openssl/core_names.h>
#include <openssl/params.h>
#include <openssl/provider.h>
#include <openssl/store.h>
#if OPENSSL_WITH_ARGON2
#include <openssl/thread.h>
#endif
Expand Down Expand Up @@ -74,6 +75,7 @@ namespace {
using BignumCtxPointer = DeleteFnPtr<BN_CTX, BN_CTX_free>;
using BignumGenCallbackPointer = DeleteFnPtr<BN_GENCB, BN_GENCB_free>;
using NetscapeSPKIPointer = DeleteFnPtr<NETSCAPE_SPKI, NETSCAPE_SPKI_free>;
using X509PubKeyPointer = DeleteFnPtr<X509_PUBKEY, X509_PUBKEY_free>;

static constexpr int kX509NameFlagsRFC2253WithinUtf8JSON =
XN_FLAG_RFC2253 & ~ASN1_STRFLGS_ESC_MSB & ~ASN1_STRFLGS_ESC_CTRL;
Expand Down Expand Up @@ -618,6 +620,26 @@ int PasswordCallback(char* buf, int size, int rwflag, void* u) {
return -1;
}

struct StorePassphraseData {
Buffer<char> 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<StorePassphraseData*>(u);
if (data == nullptr || !data->has_passphrase) {
if (data != nullptr) data->missing_passphrase = true;
return -1;
}

size_t buflen = static_cast<size_t>(size);
size_t len = data->passphrase.len;
if (buflen < len) return -1;
memcpy(buf, reinterpret_cast<const char*>(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;
Expand Down Expand Up @@ -2613,6 +2635,102 @@ EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryParsePrivateKey(
};
}

EVPKeyPointer::ParseKeyResult EVPKeyPointer::TryLoadPrivateKeyFromStore(
const StorePrivateKeyConfig& config) {
#if defined(OPENSSL_IS_BORINGSSL) || OPENSSL_VERSION_MAJOR < 3
return ParseKeyResult(PKParseError::FAILED);
#else
ClearErrorOnReturn clear_error_on_return;
std::string uri_str(config.uri);
std::string properties_str;
const char* properties = nullptr;
if (config.properties.has_value()) {
properties_str.assign(config.properties->data(), config.properties->size());
properties = properties_str.c_str();
}

std::string passphrase_str;
Buffer<char> 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<BIOPointer, bool> EVPKeyPointer::writePrivateKey(
const PrivateKeyEncodingConfig& config) const {
if (config.format == PKFormatType::JWK) {
Expand All @@ -2638,6 +2756,8 @@ Result<BIOPointer, bool> EVPKeyPointer::writePrivateKey(
#else
RSA* rsa = EVP_PKEY_get0_RSA(get());
#endif
if (rsa == nullptr) return Result<BIOPointer, bool>(false);

switch (config.format) {
case PKFormatType::PEM: {
err = PEM_write_bio_RSAPrivateKey(
Expand Down Expand Up @@ -2701,6 +2821,8 @@ Result<BIOPointer, bool> EVPKeyPointer::writePrivateKey(
#else
EC_KEY* ec = EVP_PKEY_get0_EC_KEY(get());
#endif
if (ec == nullptr) return Result<BIOPointer, bool>(false);

switch (config.format) {
case PKFormatType::PEM: {
err = PEM_write_bio_ECPrivateKey(
Expand Down Expand Up @@ -2754,6 +2876,8 @@ Result<BIOPointer, bool> EVPKeyPointer::writePublicKey(
#else
RSA* rsa = EVP_PKEY_get0_RSA(get());
#endif
if (rsa == nullptr) return Result<BIOPointer, bool>(false);

if (config.format == ncrypto::EVPKeyPointer::PKFormatType::PEM) {
// Encode PKCS#1 as PEM.
if (PEM_write_bio_RSAPublicKey(bio.get(), rsa) != 1) {
Expand All @@ -2773,7 +2897,17 @@ Result<BIOPointer, bool> EVPKeyPointer::writePublicKey(

if (config.format == ncrypto::EVPKeyPointer::PKFormatType::PEM) {
// Encode SPKI as PEM.
if (PEM_write_bio_PUBKEY(bio.get(), get()) != 1) {
// Build the SubjectPublicKeyInfo wrapper explicitly before PEM encoding.
// Provider-backed keys can fail the direct PEM_write_bio_PUBKEY() path even
// when OpenSSL can materialize the public wrapper with X509_PUBKEY_set().
X509_PUBKEY* pubkey = nullptr;
if (X509_PUBKEY_set(&pubkey, get()) != 1) {
X509_PUBKEY_free(pubkey);
return Result<BIOPointer, bool>(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<BIOPointer, bool>(false,
mark_pop_error_on_return.peekError());
}
Expand Down Expand Up @@ -2842,14 +2976,45 @@ std::optional<uint32_t> EVPKeyPointer::getBytesOfRS() const {

if (id == EVP_PKEY_DSA) {
const DSA* dsa_key = EVP_PKEY_get0_DSA(get());
bool has_bits = false;
// Both r and s are computed mod q, so their width is limited by that of q.
bits = BignumPointer::GetBitCount(DSA_get0_q(dsa_key));
if (dsa_key != nullptr) {
const BIGNUM* q = DSA_get0_q(dsa_key);
if (q != nullptr) {
bits = BignumPointer::GetBitCount(q);
has_bits = true;
}
}
#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL)
if (!has_bits &&
EVP_PKEY_get_int_param(get(), OSSL_PKEY_PARAM_FFC_QBITS, &bits) == 1) {
has_bits = true;
}
#endif
if (!has_bits) return std::nullopt;
} else if (id == EVP_PKEY_EC) {
bits = EC_GROUP_order_bits(ECKeyPointer::GetGroup(*this));
const EC_KEY* ec_key = EVP_PKEY_get0_EC_KEY(get());
bool has_bits = false;
if (ec_key != nullptr) {
const EC_GROUP* group = ECKeyPointer::GetGroup(ec_key);
if (group != nullptr) {
bits = EC_GROUP_order_bits(group);
has_bits = true;
}
}
#if OPENSSL_VERSION_MAJOR >= 3 && !defined(OPENSSL_IS_BORINGSSL)
if (!has_bits &&
EVP_PKEY_get_int_param(get(), OSSL_PKEY_PARAM_BITS, &bits) == 1) {
has_bits = true;
}
#endif
if (!has_bits) return std::nullopt;
} else {
return std::nullopt;
}

if (bits <= 0) return std::nullopt;

return (bits + 7) / 8;
}

Expand Down Expand Up @@ -2880,16 +3045,18 @@ EVPKeyPointer::operator Dsa() const {

bool EVPKeyPointer::validateDsaParameters() const {
if (!pkey_) return false;
/* Validate DSA2 parameters from FIPS 186-4 */
#if OPENSSL_VERSION_MAJOR >= 3
if (EVP_default_properties_is_fips_enabled(nullptr) && EVP_PKEY_DSA == id()) {
#else
if (FIPS_mode() && EVP_PKEY_DSA == id()) {
#endif
// Validate DSA2 parameters from FIPS 186-4.
const DSA* dsa = EVP_PKEY_get0_DSA(pkey_.get());
if (dsa == nullptr) return false;
const BIGNUM* p;
const BIGNUM* q;
DSA_get0_pqg(dsa, &p, &q, nullptr);
if (p == nullptr || q == nullptr) return false;
int L = BignumPointer::GetBitCount(p);
int N = BignumPointer::GetBitCount(q);

Expand Down
14 changes: 14 additions & 0 deletions deps/ncrypto/ncrypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,7 @@ class EVPKeyPointer final {
RAW_PUBLIC,
RAW_PRIVATE,
RAW_SEED,
STORE,
};

enum class PKParseError { NOT_RECOGNIZED, NEED_PASSPHRASE, FAILED };
Expand Down Expand Up @@ -978,6 +979,12 @@ class EVPKeyPointer final {
PrivateKeyEncodingConfig& operator=(const PrivateKeyEncodingConfig&);
};

struct StorePrivateKeyConfig {
std::string_view uri;
std::optional<std::string_view> properties = std::nullopt;
std::optional<Buffer<const char>> passphrase = std::nullopt;
};

static ParseKeyResult TryParsePublicKey(
const PublicKeyEncodingConfig& config,
const Buffer<const unsigned char>& buffer);
Expand All @@ -989,6 +996,13 @@ class EVPKeyPointer final {
const PrivateKeyEncodingConfig& config,
const Buffer<const unsigned char>& buffer);

// Loads a private key from an OpenSSL OSSL_STORE URI (e.g. "file:", a
// provider-backed scheme such as "pkcs11:"). The optional passphrase is
// used as the PIN/passphrase for encrypted or token-protected keys.
// Returns NOT_RECOGNIZED when no private key is found at the URI.
static ParseKeyResult TryLoadPrivateKeyFromStore(
const StorePrivateKeyConfig& config);

EVPKeyPointer() = default;
explicit EVPKeyPointer(EVP_PKEY* pkey);
EVPKeyPointer(EVPKeyPointer&& other) noexcept;
Expand Down
20 changes: 20 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,21 @@ This behavior also applies to `child_process.spawn()`, but in that case, the
flags are propagated via the `NODE_OPTIONS` environment variable rather than
directly through the process arguments.

### `--allow-crypto-store`

<!-- YAML
added: REPLACEME
-->

> Stability: 1.1 - Active development
When using the [Permission Model][], the process will not be able to load
private keys from OpenSSL store URIs (passed as a {URL} to
[`crypto.createPrivateKey()`][]) by default. Attempts to do so will throw an
`ERR_ACCESS_DENIED` unless the user explicitly passes the
`--allow-crypto-store` flag. This permission can be dropped at runtime via
[`permission.drop()`][].

### `--allow-ffi`

<!-- YAML
Expand Down Expand Up @@ -2339,6 +2354,7 @@ following permissions are restricted:
* WASI - manageable through [`--allow-wasi`][] flag
* Addons - manageable through [`--allow-addons`][] flag
* FFI - manageable through [`--allow-ffi`](#--allow-ffi) flag
* Crypto Store - manageable through [`--allow-crypto-store`][] flag

### `--permission-audit`

Expand Down Expand Up @@ -3789,6 +3805,7 @@ one is included in the list below.

* `--allow-addons`
* `--allow-child-process`
* `--allow-crypto-store`
* `--allow-ffi`
* `--allow-fs-read`
* `--allow-fs-write`
Expand Down Expand Up @@ -4433,6 +4450,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`"type"`]: packages.md#type
[`--allow-addons`]: #--allow-addons
[`--allow-child-process`]: #--allow-child-process
[`--allow-crypto-store`]: #--allow-crypto-store
[`--allow-fs-read`]: #--allow-fs-read
[`--allow-fs-write`]: #--allow-fs-write
[`--allow-net`]: #--allow-net
Expand Down Expand Up @@ -4466,6 +4484,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`NO_COLOR`]: https://no-color.org
[`Web Storage`]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328
[`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options
Expand All @@ -4476,6 +4495,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`node:sqlite`]: sqlite.md
[`node:stream/iter`]: stream_iter.md
[`node:vfs`]: vfs.md
[`permission.drop()`]: permissions.md#permissiondropscope-reference
[`process.setUncaughtExceptionCaptureCallback()`]: process.md#processsetuncaughtexceptioncapturecallbackfn
[`tls.DEFAULT_MAX_VERSION`]: tls.md#tlsdefault_max_version
[`tls.DEFAULT_MIN_VERSION`]: tls.md#tlsdefault_min_version
Expand Down
Loading
Loading