Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2f317b0
node:crypto: size cipher output with BoringSSL's sized calls
claude Oct 31, 2026
5513cb7
node:crypto: compute DH secrets with DH_compute_key_padded
claude Oct 31, 2026
47abf9a
crypto.getRandomValues: fill from RAND_bytes, convert the argument li…
claude Oct 31, 2026
1dd39b4
node:crypto: add mgf1Hash to publicEncrypt() and privateDecrypt()
claude Oct 31, 2026
da1758f
x509.checkIP(): a string with a NUL is not an IP address
claude Oct 31, 2026
7025617
crypto.getCipherInfo(): validate keyLength and ivLength as uint32
claude Oct 31, 2026
ed7258b
RSA JWK import: n must be p times q, and oth is rejected
claude Oct 31, 2026
4cae008
webcrypto: deriveBits length range, EC pkcs8 export, SyntaxError as a…
claude Oct 31, 2026
17ee452
node:crypto: call BoringSSL's sized cipher functions directly
claude Nov 1, 2026
2360868
webcrypto: the SyntaxError DOMException has a stack
claude Nov 1, 2026
dd1f833
webcrypto: add the EC public key when exporting pkcs8, on a copy
claude Nov 1, 2026
50183b4
node:crypto: read an RSA JWK the way Node's ImportJWKRsaKey does
claude Nov 1, 2026
71b37d3
node:crypto: the OAEP options under another padding, and Node's names…
claude Nov 1, 2026
ac818f0
crypto.getRandomValues: ERR_MISSING_ARGS with no argument
claude Nov 1, 2026
6ba4781
webcrypto: [EnforceRange] in SubtleCrypto.idl, and getCipherInfo's wh…
claude Nov 1, 2026
dc54501
node:wasi: random_get fills 64 KiB at a time
claude Nov 1, 2026
e04456f
crypto.getRandomValues: count the arguments with bindgen's requiredAr…
claude Nov 1, 2026
f2af886
crypto.getRandomValues: keep filling a view on shared or resizable me…
claude Nov 1, 2026
c88dd16
crypto-rsa.test.js: give publicDecrypt a real signature in the OAEP o…
claude Nov 1, 2026
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
6 changes: 2 additions & 4 deletions src/js/node/wasi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1663,10 +1663,8 @@ class WASI {
},
random_get: (bufPtr: number, bufLen: number) => {
this.refreshMemory();
// getRandomValues takes one integer-typed view and ignores any further
// arguments, so a bare `buffer, bufPtr, bufLen` randomized all of linear
// memory rather than the requested window.
crypto.getRandomValues(new Uint8Array(this.memory.buffer, bufPtr, bufLen));
// getRandomValues refuses a view on shared memory, so it fills an array of its own.
new Uint8Array(this.memory.buffer, bufPtr, bufLen).set(crypto.getRandomValues(new Uint8Array(bufLen)));
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
return WASI_ESUCCESS;
},
sched_yield() {
Expand Down
5 changes: 3 additions & 2 deletions src/jsc/bindings/JSX509Certificate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -581,9 +581,10 @@ bool JSX509Certificate::checkIP(JSGlobalObject* globalObject, WTF::StringView ip
VM& vm = globalObject->vm();
auto scope = DECLARE_THROW_SCOPE(vm);

// utf8() asserts on failure. A string too long to convert isn't an IP address.
// utf8() asserts on failure. A string too long to convert isn't an IP address, and neither is one with a NUL,
// where the C string that checkIp() takes would end.
auto ipUtf8 = ip.tryGetUTF8();
auto result = ipUtf8 ? view().checkIp(ipUtf8->legacyCStringPointer(), 0) : ncrypto::X509View::CheckMatch::INVALID_NAME;
auto result = ipUtf8 && !ip.contains(u'\0') ? view().checkIp(ipUtf8->legacyCStringPointer(), 0) : ncrypto::X509View::CheckMatch::INVALID_NAME;
return handleMatchResult(globalObject, "Invalid IP address"_s, scope, result);
}

Expand Down
60 changes: 48 additions & 12 deletions src/jsc/bindings/ncrypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1495,17 +1495,8 @@ DataPointer DHPointer::computeSecret(const BignumPointer& peer) const
auto dp = DataPointer::Alloc(size());
if (!dp) return {};

int size = DH_compute_key(static_cast<uint8_t*>(dp.get()), peer.get(), dh_.get());
if (size < 0) return {};

// The size of the computed key can be smaller than the size of the DH key.
// We want to make sure that the key is correctly padded.
if (static_cast<size_t>(size) < dp.size()) {
const size_t padding = dp.size() - size;
uint8_t* data = static_cast<uint8_t*>(dp.get());
memmove(data + padding, data, size);
memset(data, 0, padding);
}
// Not upstream, which calls DH_compute_key and puts back the leading zeros that it strips.
if (DH_compute_key_padded(static_cast<uint8_t*>(dp.get()), peer.get(), dh_.get()) < 0) return {};

return dp;
}
Expand Down Expand Up @@ -2897,6 +2888,30 @@ bool CipherCtxPointer::update(const Buffer<const unsigned char>& in,
return EVP_CipherFinal_ex(ctx_.get(), out, out_len) == 1;
}

size_t CipherCtxPointer::maxUpdateSize(size_t in_len) const
{
if (!ctx_) return 0;
return EVP_CIPHER_CTX_max_next_update(ctx_.get(), in_len);
}

size_t CipherCtxPointer::maxFinalSize() const
{
if (!ctx_) return 0;
return EVP_CIPHER_CTX_max_final(ctx_.get());
}

bool CipherCtxPointer::update(const Buffer<const unsigned char>& in, std::span<unsigned char> out, size_t* out_len)
{
if (!ctx_) return false;
return EVP_CipherUpdate_ex(ctx_.get(), out.data(), out_len, out.size(), in.data, in.len) == 1;
}

bool CipherCtxPointer::finalize(std::span<unsigned char> out, size_t* out_len)
{
if (!ctx_) return false;
return EVP_CipherFinal_ex2(ctx_.get(), out.data(), out_len, out.size()) == 1;
}

bool CipherCtxPointer::getAeadTag(size_t len, unsigned char* out)
{
if (!ctx_) return false;
Expand Down Expand Up @@ -3292,6 +3307,13 @@ bool EVPKeyCtxPointer::setRsaOaepMd(const Digest& md)
return EVP_PKEY_CTX_set_rsa_oaep_md(ctx_.get(), md_ptr) > 0;
}

bool EVPKeyCtxPointer::setRsaMgf1Md(const Digest& md)
{
if (!md || !ctx_) return false;
const EVP_MD* md_ptr = md;
return EVP_PKEY_CTX_set_rsa_mgf1_md(ctx_.get(), md_ptr) > 0;
}

bool EVPKeyCtxPointer::setRsaPadding(int padding)
{
return setRsaPadding(ctx_.get(), padding, std::nullopt);
Expand Down Expand Up @@ -3420,7 +3442,7 @@ DataPointer CipherImpl(const EVPKeyPointer& key,
{
if (!key) return {};
EVPKeyCtxPointer ctx = key.newCtx();
if (!ctx || init(ctx.get()) <= 0 || !ctx.setRsaPadding(params.padding) || (params.digest != nullptr && !ctx.setRsaOaepMd(params.digest))) {
if (!ctx || init(ctx.get()) <= 0 || !ctx.setRsaPadding(params.padding) || (params.digest != nullptr && !ctx.setRsaOaepMd(params.digest)) || (params.mgf1_digest != nullptr && !ctx.setRsaMgf1Md(params.mgf1_digest))) {
return {};
}

Expand Down Expand Up @@ -3481,6 +3503,20 @@ const Rsa::PrivateKey Rsa::getPrivateKey() const
return key;
}

// Upstream also multiplies by the primes of getOtherPrimeInfos(). BoringSSL has no multi-prime RSA.
bool Rsa::checkPrimeProduct() const
{
const auto pub = getPublicKey();
const auto priv = getPrivateKey();
if (pub.n == nullptr || priv.p == nullptr || priv.q == nullptr) return false;
auto product = BignumPointer::New();
BignumCtxPointer ctx(BN_CTX_new());
if (!product || !ctx || BN_mul(product.get(), priv.p, priv.q, ctx.get()) != 1) {
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
return false;
}
return BN_cmp(product.get(), pub.n) == 0;
}

const std::optional<Rsa::PssParams> Rsa::getPssParams() const
{
if (rsa_ == nullptr) return std::nullopt;
Expand Down
10 changes: 10 additions & 0 deletions src/jsc/bindings/ncrypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ class Cipher final {
struct CipherParams {
int padding;
Digest digest;
Digest mgf1_digest;
const Buffer<const void> label;
};

Expand Down Expand Up @@ -401,6 +402,7 @@ class Rsa final {

const PublicKey getPublicKey() const;
const PrivateKey getPrivateKey() const;
bool checkPrimeProduct() const;
const std::optional<PssParams> getPssParams() const;

bool setPublicKey(BignumPointer&& n, BignumPointer&& e);
Expand Down Expand Up @@ -641,6 +643,13 @@ class CipherCtxPointer final {
unsigned char* out,
int* out_len,
bool finalize = false);

// Not upstream: BoringSSL's calls that are told how much room `out` has, and how much they need at most.
size_t maxUpdateSize(size_t in_len) const;
size_t maxFinalSize() const;
bool update(const Buffer<const unsigned char>& in, std::span<unsigned char> out, size_t* out_len);
bool finalize(std::span<unsigned char> out, size_t* out_len);

bool getAeadTag(size_t len, unsigned char* out);

private:
Expand Down Expand Up @@ -673,6 +682,7 @@ class EVPKeyCtxPointer final {
bool setEcParameters(int curve, int encoding);

bool setRsaOaepMd(const Digest& md);
bool setRsaMgf1Md(const Digest& md);
bool setRsaPadding(int padding);
bool setRsaKeygenPubExp(BignumPointer&& e);
bool setRsaKeygenBits(int bits);
Expand Down
20 changes: 19 additions & 1 deletion src/jsc/bindings/node/crypto/JSCipher.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ JSValue rsaFunction(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* ca
JSValue optionsValue = callFrame->argument(0);
JSValue bufferValue = callFrame->argument(1);

// Like Node, validate the cipher options (padding, oaepHash, oaepLabel) before any key
// Like Node, validate the cipher options (padding, oaepHash, mgf1Hash, oaepLabel) before any key
// parsing so an invalid digest is reported even when the key material itself is invalid.
ncrypto::Digest digest;
ncrypto::Digest mgf1Digest;
int32_t padding = defaultPadding;
std::optional<WTF::Vector<uint8_t>> oaepLabel;
JSValue encodingValue = jsUndefined();
Expand Down Expand Up @@ -106,6 +107,22 @@ JSValue rsaFunction(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* ca
}
}

JSValue mgf1HashValue = options->get(lexicalGlobalObject, Identifier::fromString(vm, "mgf1Hash"_s));
RETURN_IF_EXCEPTION(scope, {});
if (!mgf1HashValue.isUndefined()) {
V::validateString(scope, lexicalGlobalObject, mgf1HashValue, "options.mgf1Hash"_s);
RETURN_IF_EXCEPTION(scope, {});
JSString* mgf1HashString = mgf1HashValue.toString(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
GCOwnedDataScope<WTF::StringView> mgf1HashView = mgf1HashString->view(lexicalGlobalObject);
RETURN_IF_EXCEPTION(scope, {});
mgf1Digest = ncrypto::Digest::FromName(mgf1HashView);
if (!mgf1Digest) {
ERR::OSSL_EVP_INVALID_DIGEST(scope, lexicalGlobalObject);
return {};
}
}

encodingValue = options->get(lexicalGlobalObject, Identifier::fromString(vm, "encoding"_s));
RETURN_IF_EXCEPTION(scope, {});

Expand Down Expand Up @@ -195,6 +212,7 @@ JSValue rsaFunction(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* ca
ncrypto::Cipher::CipherParams cipherParams {
.padding = padding,
.digest = digest,
.mgf1_digest = mgf1Digest,
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
.label = labelBuf,
};

Expand Down
79 changes: 51 additions & 28 deletions src/jsc/bindings/node/crypto/JSCipherPrototype.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ void JSCipherPrototype::finishCreation(JSC::VM& vm)
Bun::putToStringTagWithoutTransition(vm, this, info());
}

// The sizes BoringSSL gives up front are upper bounds. The result's ArrayBuffer is as long as what was written, like node's.
static RefPtr<ArrayBuffer> trimToWritten(RefPtr<ArrayBuffer>&& buffer, size_t written)
{
if (written == buffer->byteLength())
return WTF::move(buffer);
return ArrayBuffer::tryCreate(buffer->span().first(written));
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
}

JSC_DEFINE_HOST_FUNCTION(jsCipherUpdate, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
{
auto& vm = lexicalGlobalObject->vm();
Expand All @@ -56,18 +64,21 @@ JSC_DEFINE_HOST_FUNCTION(jsCipherUpdate, (JSC::JSGlobalObject * lexicalGlobalObj
JSArrayBufferView* dataView = getArrayBufferOrView(lexicalGlobalObject, scope, dataValue, "data"_s, encodingValue);
RETURN_IF_EXCEPTION(scope, {});

// Read once: the length of a length-tracking view is not fixed.
const std::span<const uint8_t> data = dataView->span();

ClearErrorOnReturn clearErrorOnReturn;

if (dataView->byteLength() > INT_MAX) {
return ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "data is too big"_s, 0, INT_MAX, jsNumber(dataView->byteLength()));
if (data.size() > INT_MAX) {
return ERR::OUT_OF_RANGE(scope, lexicalGlobalObject, "data is too big"_s, 0, INT_MAX, jsNumber(data.size()));
}

if (!cipher->m_ctx) {
throwCryptoError(lexicalGlobalObject, scope, ERR_peek_error(), "Trying to add data in unsupported state");
return {};
}

if (cipher->m_ctx.isCcmMode() && !cipher->checkCCMMessageLength(dataView->byteLength())) {
if (cipher->m_ctx.isCcmMode() && !cipher->checkCCMMessageLength(data.size())) {
// return undefined
// https://github.com/nodejs/node/blob/6b4255434226491449b7d925038008439e5586b2/src/crypto/crypto_cipher.cc#L742
return JSValue::encode(jsUndefined());
Expand All @@ -79,48 +90,55 @@ JSC_DEFINE_HOST_FUNCTION(jsCipherUpdate, (JSC::JSGlobalObject * lexicalGlobalObj
}

const int32_t blockSize = cipher->m_ctx.getBlockSize();
if (dataView->byteLength() + blockSize > INT_MAX) {
if (data.size() + blockSize > INT_MAX) {
throwCryptoError(lexicalGlobalObject, scope, ERR_peek_error(), "Trying to add data in unsupported state");
return {};
}
int32_t bufLen = dataView->byteLength() + blockSize;

ncrypto::Buffer<const uint8_t> buf {
.data = reinterpret_cast<uint8_t*>(dataView->vector()),
.len = dataView->byteLength(),
.data = data.data(),
.len = data.size(),
};

if (cipher->m_kind == CipherKind::Cipher && cipher->m_ctx.isWrapMode() && !cipher->m_ctx.update(buf, nullptr, &bufLen)) {
throwCryptoError(lexicalGlobalObject, scope, ERR_peek_error(), "Trying to add data in unsupported state");
return {};
size_t maxOutLen = cipher->m_ctx.maxUpdateSize(data.size());
if (cipher->m_kind == CipherKind::Cipher && cipher->m_ctx.isWrapMode()) {
int32_t wrappedLen = 0;
if (!cipher->m_ctx.update(buf, nullptr, &wrappedLen)) {
throwCryptoError(lexicalGlobalObject, scope, ERR_peek_error(), "Trying to add data in unsupported state");
return {};
}
maxOutLen = wrappedLen;
}

RefPtr<ArrayBuffer> outBuf = JSC::ArrayBuffer::tryCreateUninitialized(cipher->heldBackBlock().size() + bufLen, 1);
RefPtr<ArrayBuffer> outBuf = JSC::ArrayBuffer::tryCreateUninitialized(cipher->heldBackBlock().size() + maxOutLen, 1);
if (!outBuf) {
throwOutOfMemoryError(lexicalGlobalObject, scope);
return {};
}
const size_t heldBack = cipher->takeHeldBackBlock(outBuf->mutableSpan());
size_t outLen = cipher->takeHeldBackBlock(outBuf->mutableSpan());

buf = {
.data = reinterpret_cast<uint8_t*>(dataView->vector()),
.len = dataView->byteLength(),
};

bool res = cipher->m_ctx.update(buf, static_cast<unsigned char*>(outBuf->data()) + heldBack, &bufLen);
ASSERT(heldBack + bufLen <= outBuf->byteLength());
size_t written = 0;
bool res = cipher->m_ctx.update(buf, outBuf->mutableSpan().subspan(outLen), &written);
ensureStillAliveHere(dataView);
outLen += written;

if (!res && cipher->m_kind == CipherKind::Decipher && cipher->m_ctx.isCcmMode()) {
cipher->m_pendingAuthFailed = true;
RELEASE_AND_RETURN(scope, JSValue::encode(JSUint8Array::create(lexicalGlobalObject, globalObject->JSBufferSubclassStructure(), 0)));
}

if (res != 1) {
if (!res) {
throwCryptoError(lexicalGlobalObject, scope, ERR_peek_error(), "Trying to add data in unsupported state");
return {};
}

RELEASE_AND_RETURN(scope, JSValue::encode(JSUint8Array::create(lexicalGlobalObject, globalObject->JSBufferSubclassStructure(), WTF::move(outBuf), 0, heldBack + bufLen)));
outBuf = trimToWritten(WTF::move(outBuf), outLen);
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
if (!outBuf) {
throwOutOfMemoryError(lexicalGlobalObject, scope);
return {};
}

RELEASE_AND_RETURN(scope, JSValue::encode(JSUint8Array::create(lexicalGlobalObject, globalObject->JSBufferSubclassStructure(), WTF::move(outBuf), 0, outLen)));
}

JSC_DEFINE_HOST_FUNCTION(jsCipherFinal, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
Expand Down Expand Up @@ -149,13 +167,12 @@ JSC_DEFINE_HOST_FUNCTION(jsCipherFinal, (JSC::JSGlobalObject * lexicalGlobalObje
throwCryptoError(globalObject, scope, ERR_peek_error(), isAuthMode ? "Unsupported state or unable to authenticate data" : "Unsupported state");
};

int32_t outLen = cipher->m_ctx.getBlockSize();
RefPtr<ArrayBuffer> outBuf = ArrayBuffer::tryCreateUninitialized(cipher->heldBackBlock().size() + outLen, 1);
RefPtr<ArrayBuffer> outBuf = ArrayBuffer::tryCreateUninitialized(cipher->heldBackBlock().size() + cipher->m_ctx.maxFinalSize(), 1);
if (!outBuf) {
throwOutOfMemoryError(lexicalGlobalObject, scope);
return {};
}
const size_t heldBack = cipher->takeHeldBackBlock(outBuf->mutableSpan());
size_t outLen = cipher->takeHeldBackBlock(outBuf->mutableSpan());

if (cipher->m_kind == CipherKind::Decipher && Cipher::FromCtx(cipher->m_ctx).isSupportedAuthenticatedMode()) {
cipher->maybePassAuthTagToOpenSSL();
Expand All @@ -169,10 +186,10 @@ JSC_DEFINE_HOST_FUNCTION(jsCipherFinal, (JSC::JSGlobalObject * lexicalGlobalObje
bool ok;
if (cipher->m_kind == CipherKind::Decipher && cipher->m_ctx.isCcmMode()) {
ok = !cipher->m_pendingAuthFailed;
outLen = 0;
} else {
ok = cipher->m_ctx.update({}, static_cast<unsigned char*>(outBuf->data()) + heldBack, &outLen, true);
ASSERT(heldBack + outLen <= outBuf->byteLength());
size_t written = 0;
ok = cipher->m_ctx.finalize(outBuf->mutableSpan().subspan(outLen), &written);
outLen += written;

if (ok && cipher->m_kind == CipherKind::Cipher && cipher->isAuthenticatedMode()) {
ASSERT(cipher->m_authTagLen.has_value());
Expand All @@ -188,7 +205,13 @@ JSC_DEFINE_HOST_FUNCTION(jsCipherFinal, (JSC::JSGlobalObject * lexicalGlobalObje
return {};
}

RELEASE_AND_RETURN(scope, JSValue::encode(JSUint8Array::create(lexicalGlobalObject, globalObject->JSBufferSubclassStructure(), WTF::move(outBuf), 0, heldBack + outLen)));
outBuf = trimToWritten(WTF::move(outBuf), outLen);
if (!outBuf) {
throwOutOfMemoryError(lexicalGlobalObject, scope);
return {};
}

RELEASE_AND_RETURN(scope, JSValue::encode(JSUint8Array::create(lexicalGlobalObject, globalObject->JSBufferSubclassStructure(), WTF::move(outBuf), 0, outLen)));
}

JSC_DEFINE_HOST_FUNCTION(jsCipherSetAutoPadding, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
Expand Down
Loading
Loading