Commit d36e9ba1f8b for nodejs
commit d36e9ba1f8b1d6977a8efdf91263d98a1b4af910
Author: Filip Skokan <panva.ip@gmail.com>
Date: Wed Nov 4 06:57:21 2026 +0100
v8: support X509Certificate serialization
v8.serialize() currently rejects X509Certificate objects. Add a host
object encoding containing the certificate's DER snapshot, including
its issuerCertificate chain, and reconstruct fresh X509Certificate
objects in v8.deserialize().
Read the stored snapshot rather than the cached raw Buffer, which can
be modified by callers. Decode each certificate independently without
imposing issuer relationships on the serialized sequence.
Assisted-by: Codex
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/67083
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
Reviewed-By: Tim Perry <pimterry@gmail.com>
diff --git a/lib/internal/crypto/x509.js b/lib/internal/crypto/x509.js
index cf1de447475..0a0da0296a8 100644
--- a/lib/internal/crypto/x509.js
+++ b/lib/internal/crypto/x509.js
@@ -9,7 +9,9 @@ const {
const {
createX509CertificateClass,
+ getX509CertificateSnapshot: getNativeX509CertificateSnapshot,
isX509Certificate: isNativeX509Certificate,
+ parseX509Snapshot,
X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT,
X509_CHECK_FLAG_NEVER_CHECK_SUBJECT,
X509_CHECK_FLAG_NO_WILDCARDS,
@@ -427,8 +429,22 @@ const {
// Keep the binding-returned constructor and the closure reference aligned.
InternalX509Certificate = InternalX509CertificateConstructor;
+function getX509CertificateSnapshot(certificate) {
+ if (!isX509Certificate(certificate)) {
+ throw new ERR_INVALID_ARG_TYPE(
+ 'certificate', 'X509Certificate', certificate);
+ }
+ return getNativeX509CertificateSnapshot(certificate);
+}
+
+function createX509CertificateFromSnapshot(snapshot) {
+ return new InternalX509Certificate(parseX509Snapshot(snapshot));
+}
+
module.exports = {
X509Certificate,
InternalX509Certificate,
+ createX509CertificateFromSnapshot,
+ getX509CertificateSnapshot,
isX509Certificate,
};
diff --git a/lib/v8.js b/lib/v8.js
index ea3366bb961..6ae1d2c8ee6 100644
--- a/lib/v8.js
+++ b/lib/v8.js
@@ -16,6 +16,7 @@
const {
Array,
+ ArrayBufferIsView,
BigInt64Array,
BigUint64Array,
DataView,
@@ -27,6 +28,7 @@ const {
Int32Array,
Int8Array,
JSONParse,
+ ObjectDefineProperty,
ObjectPrototypeToString,
SymbolDispose,
Uint16Array,
@@ -55,12 +57,22 @@ const {
normalizeCpuProfileOptions,
} = require('internal/v8/cpu_profiler');
+const {
+ hasInspector,
+ hasOpenSSL,
+} = internalBinding('config');
+
let profiler = {};
-if (internalBinding('config').hasInspector) {
+if (hasInspector) {
profiler = internalBinding('profiler');
}
const assert = require('internal/assert');
+const {
+ codes: {
+ ERR_INVALID_STATE,
+ },
+} = require('internal/errors');
const { inspect } = require('internal/util/inspect');
const { FastBuffer } = require('internal/buffer');
const { getValidatedPath } = require('internal/fs/utils');
@@ -438,6 +450,14 @@ function arrayBufferViewIndexToType(index) {
return undefined;
}
+const kX509CertificateHostObject = 14;
+let x509Serialization;
+
+function getX509Serialization() {
+ x509Serialization ??= require('internal/crypto/x509');
+ return x509Serialization;
+}
+
class DefaultSerializer extends Serializer {
constructor() {
super();
@@ -459,6 +479,23 @@ class DefaultSerializer extends Serializer {
// serialization. Because a) those may contain sensitive data and the user
// may not be aware of that and b) they are often much larger than the
// `Buffer` itself, custom serialization is applied.
+ if (!ArrayBufferIsView(abView) && hasOpenSSL) {
+ const {
+ getX509CertificateSnapshot,
+ isX509Certificate,
+ } = getX509Serialization();
+ if (isX509Certificate(abView)) {
+ const snapshot = getX509CertificateSnapshot(abView);
+ this.writeUint32(kX509CertificateHostObject);
+ this.writeUint32(snapshot.length);
+ for (let j = 0; j < snapshot.length; j++) {
+ this.writeUint32(snapshot[j].byteLength);
+ this.writeRawBytes(snapshot[j]);
+ }
+ return;
+ }
+ }
+
let i = 10; // FastBuffer
if (abView.constructor !== Buffer) {
i = arrayBufferViewTypeToIndex(abView);
@@ -483,6 +520,25 @@ class DefaultDeserializer extends Deserializer {
*/
_readHostObject() {
const typeIndex = this.readUint32();
+ if (typeIndex === kX509CertificateHostObject) {
+ const count = this.readUint32();
+ if (!hasOpenSSL || count === 0) {
+ throw new ERR_INVALID_STATE('Invalid serialized X509Certificate');
+ }
+ const snapshot = new Array(count);
+ for (let i = 0; i < count; i++) {
+ const byteLength = this.readUint32();
+ ObjectDefineProperty(snapshot, i, {
+ __proto__: null,
+ configurable: true,
+ enumerable: true,
+ value: this.readRawBytes(byteLength),
+ writable: true,
+ });
+ }
+ const { createX509CertificateFromSnapshot } = getX509Serialization();
+ return createX509CertificateFromSnapshot(snapshot);
+ }
const ctor = arrayBufferViewIndexToType(typeIndex);
const byteLength = this.readUint32();
const byteOffset = this._readRawBytes(byteLength);
diff --git a/src/crypto/crypto_x509.cc b/src/crypto/crypto_x509.cc
index 3c208082f94..3a9c296970a 100644
--- a/src/crypto/crypto_x509.cc
+++ b/src/crypto/crypto_x509.cc
@@ -128,6 +128,23 @@ std::shared_ptr<const X509CertificateSnapshot> X509CertificateSnapshot::Create(
new X509CertificateSnapshot(std::move(certificates)));
}
+std::shared_ptr<const X509CertificateSnapshot> X509CertificateSnapshot::Create(
+ std::vector<DER> certificates) {
+ if (certificates.empty()) return {};
+
+ for (auto& certificate : certificates) {
+ auto cert = ParseCertificate(certificate);
+ if (!cert) return {};
+
+ auto der = EncodeCertificate(cert.view());
+ if (!der.has_value()) return {};
+ certificate = std::move(der.value());
+ }
+
+ return std::shared_ptr<const X509CertificateSnapshot>(
+ new X509CertificateSnapshot(std::move(certificates)));
+}
+
X509Pointer X509CertificateSnapshot::Parse(size_t index) const {
if (index >= certificates_.size()) return {};
return ParseCertificate(certificates_[index]);
@@ -1065,6 +1082,76 @@ void X509Certificate::NewFromHandle(const FunctionCallbackInfo<Value>& args) {
issuer);
}
+void X509Certificate::ParseSnapshot(const FunctionCallbackInfo<Value>& args) {
+ Environment* env = Environment::GetCurrent(args);
+ Local<Context> context = env->context();
+ if (args.Length() != 1 || !args[0]->IsArray()) {
+ THROW_ERR_INVALID_ARG_TYPE(env,
+ "The \"snapshot\" argument must be an Array");
+ return;
+ }
+
+ Local<Array> input = args[0].As<Array>();
+ std::vector<X509CertificateSnapshot::DER> certificates;
+ certificates.reserve(input->Length());
+ for (uint32_t i = 0; i < input->Length(); i++) {
+ Local<Value> value;
+ if (!input->Get(context, i).ToLocal(&value)) return;
+ if (!value->IsArrayBufferView()) {
+ THROW_ERR_INVALID_ARG_TYPE(
+ env, "The \"snapshot\" entries must be ArrayBufferViews");
+ return;
+ }
+
+ ArrayBufferViewContents<unsigned char> contents(
+ value.As<ArrayBufferView>());
+ certificates.emplace_back(contents.data(),
+ contents.data() + contents.length());
+ }
+
+ auto snapshot = X509CertificateSnapshot::Create(std::move(certificates));
+ if (!snapshot) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(
+ env, "Failed to create X.509 certificate snapshot");
+ return;
+ }
+
+ Local<Object> cert;
+ if (New(env, std::move(snapshot)).ToLocal(&cert)) {
+ args.GetReturnValue().Set(cert);
+ }
+}
+
+void X509Certificate::GetSnapshot(const FunctionCallbackInfo<Value>& args) {
+ Environment* env = Environment::GetCurrent(args);
+ Local<Context> context = env->context();
+ if (args.Length() != 1 || !HasInstance(env, args[0])) {
+ THROW_ERR_INVALID_ARG_TYPE(env,
+ "The \"certificate\" argument must be an "
+ "X509CertificateHandle");
+ return;
+ }
+
+ X509Certificate* cert = Unwrap<X509Certificate>(args[0].As<Object>());
+ CHECK_NOT_NULL(cert);
+ CHECK_LT(cert->snapshot_index_, cert->snapshot_->size());
+ const size_t count = cert->snapshot_->size() - cert->snapshot_index_;
+ CHECK_LE(count, static_cast<size_t>(std::numeric_limits<int>::max()));
+ Local<Array> output = Array::New(env->isolate(), static_cast<int>(count));
+ for (size_t i = 0; i < count; i++) {
+ const auto& der = cert->snapshot_->at(cert->snapshot_index_ + i);
+ Local<Object> buffer;
+ if (!Buffer::Copy(
+ env, reinterpret_cast<const char*>(der.data()), der.size())
+ .ToLocal(&buffer) ||
+ !output->CreateDataProperty(context, static_cast<uint32_t>(i), buffer)
+ .FromMaybe(false)) {
+ return;
+ }
+ }
+ args.GetReturnValue().Set(output);
+}
+
void X509Certificate::CreateX509CertificateClass(
const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
@@ -1324,6 +1411,9 @@ void X509Certificate::Initialize(Environment* env, Local<Object> target) {
SetMethodNoSideEffect(
env->context(), target, "isX509Certificate", IsX509Certificate);
SetMethod(env->context(), target, "parseX509", Parse);
+ SetMethod(env->context(), target, "parseX509Snapshot", ParseSnapshot);
+ SetMethodNoSideEffect(
+ env->context(), target, "getX509CertificateSnapshot", GetSnapshot);
NODE_DEFINE_CONSTANT(target, X509_CHECK_FLAG_ALWAYS_CHECK_SUBJECT);
NODE_DEFINE_CONSTANT(target, X509_CHECK_FLAG_NEVER_CHECK_SUBJECT);
@@ -1339,6 +1429,8 @@ void X509Certificate::RegisterExternalReferences(
registry->Register(IsX509Certificate);
registry->Register(NewFromHandle);
registry->Register(Parse);
+ registry->Register(ParseSnapshot);
+ registry->Register(GetSnapshot);
registry->Register(Subject);
registry->Register(SubjectAltName);
registry->Register(InfoAccess);
diff --git a/src/crypto/crypto_x509.h b/src/crypto/crypto_x509.h
index 44714d1bcca..e618edd8e5a 100644
--- a/src/crypto/crypto_x509.h
+++ b/src/crypto/crypto_x509.h
@@ -54,6 +54,8 @@ class X509CertificateSnapshot final : public MemoryRetainer {
ncrypto::X509View leaf,
const STACK_OF(X509) * issuer_chain = nullptr,
OPENSSL_SIZE_T issuers_start = 0);
+ static std::shared_ptr<const X509CertificateSnapshot> Create(
+ std::vector<DER> certificates);
ncrypto::X509Pointer Parse(size_t index) const;
@@ -89,6 +91,8 @@ class X509Certificate final : public BaseObject {
static void IsX509Certificate(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void NewFromHandle(const v8::FunctionCallbackInfo<v8::Value>& args);
+ static void ParseSnapshot(const v8::FunctionCallbackInfo<v8::Value>& args);
+ static void GetSnapshot(const v8::FunctionCallbackInfo<v8::Value>& args);
static v8::MaybeLocal<v8::Object> New(
Environment* env,
diff --git a/test/parallel/test-crypto-x509-chain-key-usage.js b/test/parallel/test-crypto-x509-chain-key-usage.js
new file mode 100644
index 00000000000..100e2dbb9e5
--- /dev/null
+++ b/test/parallel/test-crypto-x509-chain-key-usage.js
@@ -0,0 +1,87 @@
+// Flags: --expose-internals --no-warnings
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('node:assert');
+const { X509Certificate } = require('node:crypto');
+const { deserialize, serialize } = require('node:v8');
+const {
+ createX509CertificateFromSnapshot,
+} = require('internal/crypto/x509');
+const fixtures = require('../common/fixtures');
+
+const leaf = new X509Certificate(`-----BEGIN CERTIFICATE-----
+MIIBHTCBxaADAgECAgECMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMMD3NuYXBzaG90
+IGlzc3VlcjAeFw0yNTAxMDEwMDAwMDBaFw0zNTAxMDEwMDAwMDBaMBgxFjAUBgNV
+BAMMDXNuYXBzaG90IGxlYWYwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQYBC5j
+NN5rAvdjqeCKGhuSxH7DSi35DDi7dArQbeGw5FzdtQmOpglNYbi+TfmcRwkPouuR
+i1cCQjEtL+3TW51PMAoGCCqGSM49BAMCA0cAMEQCIE/FmO4pJThTdhYs3kHamBe9
+Qo9qR4urR0ZVNoS320q2AiBxs3QJcbhz18plOnwj2n6TTcevxtJauggAca+AVUg+
+HQ==
+-----END CERTIFICATE-----`);
+
+const issuer = new X509Certificate(`-----BEGIN CERTIFICATE-----
+MIIBQjCB6aADAgECAgEBMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMMD3NuYXBzaG90
+IGlzc3VlcjAeFw0yNTAxMDEwMDAwMDBaFw0zNTAxMDEwMDAwMDBaMBoxGDAWBgNV
+BAMMD3NuYXBzaG90IGlzc3VlcjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABG4H
+eatluz8bc/iTzQN0Ada94gDZpq5E1g+xP/6WAKXILrnvRBZhRBi2xIJExYu1QZq2
++mvnupnjSy7kJUdBz2WjIDAeMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQDAgeA
+MAoGCCqGSM49BAMCA0gAMEUCIQDqKmAVHvhW0i87KWzp8sjUWId5IVWSgi7iqJx+
+los0qwIgOALGVcPyyV56QNJGOIYCcXNLPQ9Adbs2arUVYcWGNEU=
+-----END CERTIFICATE-----`);
+
+// The issuer's Key Usage omits keyCertSign. Inspecting or copying the chain
+// does not verify its certificates.
+assert.strictEqual(leaf.verify(issuer.publicKey), true);
+assert.strictEqual(leaf.checkIssued(issuer), false);
+
+function assertChain(certificate, expected) {
+ const seen = new Set();
+ for (const raw of expected) {
+ assert(certificate instanceof X509Certificate);
+ assert(!seen.has(certificate));
+ seen.add(certificate);
+ assert.deepStrictEqual(certificate.raw, raw);
+ certificate = certificate.issuerCertificate;
+ }
+ assert.strictEqual(certificate, undefined);
+}
+
+const unrelated = new X509Certificate(fixtures.readKey('agent1-cert.pem'));
+
+// A recognized extension can be malformed without making the enclosing
+// certificate invalid DER. Snapshotting does not interpret extensions.
+const malformedIssuer = Buffer.from(issuer.raw);
+const basicConstraints = Buffer.from('30030101ff', 'hex');
+const basicConstraintsOffset = malformedIssuer.indexOf(basicConstraints);
+assert.notStrictEqual(basicConstraintsOffset, -1);
+malformedIssuer[basicConstraintsOffset] = 0x31;
+assert.deepStrictEqual(new X509Certificate(malformedIssuer).raw, malformedIssuer);
+
+for (const expected of [
+ [leaf.raw, issuer.raw],
+ [leaf.raw, leaf.raw, issuer.raw],
+ [leaf.raw, issuer.raw, leaf.raw],
+ [issuer.raw, leaf.raw],
+ [leaf.raw, unrelated.raw, issuer.raw],
+ [leaf.raw, malformedIssuer],
+]) {
+ const certificate = createX509CertificateFromSnapshot(expected);
+ assertChain(certificate, expected);
+ assertChain(structuredClone(certificate), expected);
+ assertChain(deserialize(serialize(certificate)), expected);
+}
+
+for (const snapshot of [
+ [],
+ [Buffer.alloc(0)],
+ [leaf.raw, Buffer.from('not a certificate')],
+ [leaf.raw, Buffer.concat([issuer.raw, Buffer.from([0])])],
+]) {
+ assert.throws(() => createX509CertificateFromSnapshot(snapshot), {
+ code: 'ERR_CRYPTO_OPERATION_FAILED',
+ });
+}
diff --git a/test/parallel/test-crypto-x509-chain-snapshot.js b/test/parallel/test-crypto-x509-chain-snapshot.js
index 5a0f696da0f..433abd9fcca 100644
--- a/test/parallel/test-crypto-x509-chain-snapshot.js
+++ b/test/parallel/test-crypto-x509-chain-snapshot.js
@@ -8,6 +8,7 @@ const assert = require('node:assert');
const { X509Certificate } = require('node:crypto');
const { once } = require('node:events');
const tls = require('node:tls');
+const { deserialize, serialize } = require('node:v8');
const {
MessageChannel,
Worker,
@@ -88,6 +89,7 @@ const server = tls.createServer({
assertChain(structuredClone(first));
assertChain(await cloneViaMessagePort(first));
assertChain(await cloneViaWorker(first));
+ assertChain(deserialize(serialize(first)));
socket.destroy();
server.close();
diff --git a/test/parallel/test-crypto-x509-peer-chain.js b/test/parallel/test-crypto-x509-peer-chain.js
index b60f3d00acc..d14df8eda92 100644
--- a/test/parallel/test-crypto-x509-peer-chain.js
+++ b/test/parallel/test-crypto-x509-peer-chain.js
@@ -8,6 +8,7 @@ const assert = require('node:assert');
const { X509Certificate } = require('node:crypto');
const { once } = require('node:events');
const tls = require('node:tls');
+const { deserialize, serialize } = require('node:v8');
const { MessageChannel, Worker } = require('node:worker_threads');
const fixtures = require('../common/fixtures');
@@ -38,6 +39,7 @@ function assertChain(actual, expected) {
async function assertClones(certificate, chain) {
assertChain(structuredClone(certificate), chain);
+ assertChain(deserialize(serialize(certificate)), chain);
const { port1, port2 } = new MessageChannel();
port1.postMessage(certificate);
diff --git a/test/parallel/test-v8-serdes.js b/test/parallel/test-v8-serdes.js
index 2c7a20b24f9..0c7af6d78eb 100644
--- a/test/parallel/test-v8-serdes.js
+++ b/test/parallel/test-v8-serdes.js
@@ -56,6 +56,17 @@ const hostObject = new (internalBinding('js_stream').JSStream)();
}
}
+{
+ const buffer = Buffer.from([1, 2, 3, 4]);
+ Object.defineProperty(buffer, Symbol.toStringTag, {
+ value: 'CustomBuffer',
+ });
+
+ const deserialized = v8.deserialize(v8.serialize(buffer));
+ assert(Buffer.isBuffer(deserialized));
+ assert.deepStrictEqual(deserialized, Buffer.from(buffer));
+}
+
{
const ser = new v8.DefaultSerializer();
ser._getDataCloneError = common.mustCall((message) => {
diff --git a/typings/internalBinding/crypto.d.ts b/typings/internalBinding/crypto.d.ts
index 87aeffdf230..cc0d8856ab8 100644
--- a/typings/internalBinding/crypto.d.ts
+++ b/typings/internalBinding/crypto.d.ts
@@ -972,6 +972,9 @@ export interface CryptoBinding {
getFipsCryptoGeneration(): bigint;
getHashes(): string[];
getMacs(): string[];
+ getX509CertificateSnapshot(
+ certificate: InternalCryptoBinding.X509CertificateHandle,
+ ): InternalCryptoBinding.Buffer[];
isCryptoKey(key: unknown): boolean;
isKeyAlgorithmAvailable(name: string): boolean;
isKeyObject(key: unknown): boolean;
@@ -997,6 +1000,9 @@ export interface CryptoBinding {
passphrase?: InternalCryptoBinding.ByteSource,
): InternalCryptoBinding.PKCS12ParseResult;
parseX509(data: InternalCryptoBinding.ByteSource): InternalCryptoBinding.X509CertificateHandle;
+ parseX509Snapshot(
+ snapshot: ArrayBufferView[],
+ ): InternalCryptoBinding.X509CertificateHandle;
privateDecrypt: InternalCryptoBinding.PublicKeyCipher;
privateEncrypt: InternalCryptoBinding.PublicKeyCipher;
publicDecrypt: InternalCryptoBinding.PublicKeyCipher;