Commit 3f027380b98 for nodejs
commit 3f027380b984b8251a363d07dd9b798710921844
Author: Filip Skokan <panva.ip@gmail.com>
Date: Sun Aug 30 09:04:52 2026 +0200
crypto,tls: clone X.509 chains across isolates
X509Certificate cloning drops issuerCertificate and shares its native
X509 object with the receiving isolate. Snapshot the leaf and supplied
peer chain as DER, then parse separate native certificates in each
isolate.
Construct the JavaScript chain iteratively for TLS inspection,
structuredClone(), MessagePort, and workers. Each supplied position
gets its own object, including repeated certificate encodings.
Refs: https://github.com/nodejs/node/pull/65602
Assisted-by: Codex
Signed-off-by: Filip Skokan <panva.ip@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66898
Reviewed-By: Tim Perry <pimterry@gmail.com>
diff --git a/src/crypto/crypto_x509.cc b/src/crypto/crypto_x509.cc
index c7dbe956149..3c208082f94 100644
--- a/src/crypto/crypto_x509.cc
+++ b/src/crypto/crypto_x509.cc
@@ -13,6 +13,7 @@
#include <array>
#include <limits>
+#include <numeric>
#include <string>
#include <string_view>
#include <vector>
@@ -55,17 +56,94 @@ using v8::Value;
namespace crypto {
-ManagedX509::ManagedX509(X509Pointer&& cert) : cert_(std::move(cert)) {}
+namespace {
+
+std::optional<X509CertificateSnapshot::DER> EncodeCertificate(
+ const X509View& cert) {
+ MarkPopErrorOnReturn mark_pop_error_on_return;
+ if (!cert) return std::nullopt;
+
+ const int size = i2d_X509(cert.get(), nullptr);
+ if (size <= 0) return std::nullopt;
+
+ X509CertificateSnapshot::DER der(static_cast<size_t>(size));
+ unsigned char* cursor = der.data();
+ if (i2d_X509(cert.get(), &cursor) != size ||
+ cursor != der.data() + der.size()) {
+ return std::nullopt;
+ }
+ return der;
+}
+
+X509Pointer ParseCertificate(const X509CertificateSnapshot::DER& certificate) {
+ MarkPopErrorOnReturn mark_pop_error_on_return;
+ constexpr size_t kMaximumCertificateSize = static_cast<size_t>(
+ std::numeric_limits<long>::max()); // NOLINT(runtime/int)
+ if (certificate.empty() || certificate.size() > kMaximumCertificateSize) {
+ return {};
+ }
-ManagedX509::ManagedX509(const ManagedX509& that) {
- *this = that;
+ const unsigned char* cursor = certificate.data();
+ const unsigned char* const end = cursor + certificate.size();
+ X509Pointer cert(
+ d2i_X509(nullptr,
+ &cursor,
+ static_cast<long>(certificate.size()))); // NOLINT(runtime/int)
+ if (!cert || cursor != end) return {};
+ return cert;
}
-ManagedX509& ManagedX509::operator=(const ManagedX509& that) {
- cert_.reset(that.get());
- if (cert_) [[likely]]
- X509_up_ref(cert_.get());
- return *this;
+} // namespace
+
+ManagedX509::ManagedX509(X509Pointer cert) : cert_(std::move(cert)) {}
+
+std::shared_ptr<ManagedX509> ManagedX509::Adopt(X509Pointer cert) {
+ if (!cert) return {};
+ return std::shared_ptr<ManagedX509>(new ManagedX509(std::move(cert)));
+}
+
+X509CertificateSnapshot::X509CertificateSnapshot(std::vector<DER> certificates)
+ : certificates_(std::move(certificates)) {}
+
+std::shared_ptr<const X509CertificateSnapshot> X509CertificateSnapshot::Create(
+ X509View leaf,
+ const STACK_OF(X509) * issuer_chain,
+ OPENSSL_SIZE_T issuers_start) {
+ auto leaf_der = EncodeCertificate(leaf);
+ if (!leaf_der.has_value()) return {};
+
+ const OPENSSL_SIZE_T count =
+ issuer_chain == nullptr ? 0 : sk_X509_num(issuer_chain);
+ std::vector<DER> certificates;
+ certificates.reserve(static_cast<size_t>(count) + 1);
+ certificates.push_back(std::move(leaf_der.value()));
+ for (OPENSSL_SIZE_T i = issuers_start; i < count; i++) {
+ const X509View candidate(sk_X509_value(issuer_chain, i));
+ auto candidate_der = EncodeCertificate(candidate);
+ if (!candidate_der.has_value()) return {};
+ certificates.push_back(std::move(candidate_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]);
+}
+
+size_t X509CertificateSnapshot::size_bytes() const {
+ return std::accumulate(certificates_.begin(),
+ certificates_.end(),
+ size_t{0},
+ [](size_t total, const DER& certificate) {
+ return total + certificate.size();
+ });
+}
+
+void X509CertificateSnapshot::MemoryInfo(MemoryTracker* tracker) const {
+ tracker->TrackFieldWithSize("certificates", size_bytes());
}
void ManagedX509::MemoryInfo(MemoryTracker* tracker) const {
@@ -947,9 +1025,25 @@ void X509Certificate::NewFromHandle(const FunctionCallbackInfo<Value>& args) {
if (!result.value) [[unlikely]] {
return ThrowCryptoError(env, result.error.value_or(0));
}
+
+ auto snapshot = X509CertificateSnapshot::Create(result.value.view());
+ if (!snapshot) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(env,
+ "Failed to snapshot X.509 certificate");
+ return;
+ }
+
+ auto parsed = snapshot->Parse(0);
+ if (!parsed) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(
+ env, "Failed to parse X.509 certificate snapshot");
+ return;
+ }
new X509Certificate(env,
args.This(),
- std::make_shared<ManagedX509>(std::move(result.value)));
+ ManagedX509::Adopt(std::move(parsed)),
+ std::move(snapshot),
+ 0);
return;
}
@@ -963,7 +1057,12 @@ void X509Certificate::NewFromHandle(const FunctionCallbackInfo<Value>& args) {
CHECK_NOT_NULL(handle);
Local<Object> issuer;
if (handle->issuer_cert_) issuer = handle->issuer_cert_->object();
- new X509Certificate(env, args.This(), handle->cert_, issuer);
+ new X509Certificate(env,
+ args.This(),
+ handle->cert_,
+ handle->snapshot_,
+ handle->snapshot_index_,
+ issuer);
}
void X509Certificate::CreateX509CertificateClass(
@@ -1002,50 +1101,95 @@ void X509Certificate::IsX509Certificate(
MaybeLocal<Object> X509Certificate::New(Environment* env,
X509Pointer cert,
- const STACK_OF(X509) * issuer_chain) {
- std::shared_ptr<ManagedX509> mcert(new ManagedX509(std::move(cert)));
- return New(env, std::move(mcert), issuer_chain);
-}
+ const STACK_OF(X509) * issuer_chain,
+ OPENSSL_SIZE_T issuers_start) {
+ if (!cert) return {};
+
+ auto snapshot =
+ X509CertificateSnapshot::Create(cert.view(), issuer_chain, issuers_start);
+ if (!snapshot) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(env,
+ "Failed to snapshot X.509 certificate");
+ return {};
+ }
-MaybeLocal<Object> X509Certificate::New(Environment* env,
- std::shared_ptr<ManagedX509> cert,
- const STACK_OF(X509) * issuer_chain) {
- return NewWithIssuers(env, std::move(cert), issuer_chain, 0);
+ // Keep the live certificate independent from the backend object that was
+ // serialized while creating the immutable snapshot. Some backends populate
+ // or invalidate lazy caches while encoding malformed-but-parseable input.
+ auto parsed = snapshot->Parse(0);
+ if (!parsed) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(
+ env, "Failed to parse X.509 certificate snapshot");
+ return {};
+ }
+ return New(env, ManagedX509::Adopt(std::move(parsed)), std::move(snapshot));
}
-MaybeLocal<Object> X509Certificate::NewWithIssuers(
+MaybeLocal<Object> X509Certificate::New(
Environment* env,
std::shared_ptr<ManagedX509> cert,
- const STACK_OF(X509) * issuer_chain,
- int start) {
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index) {
+ CHECK(cert);
+ CHECK(snapshot);
+ CHECK_LT(snapshot_index, snapshot->size());
+
EscapableHandleScope scope(env->isolate());
+ Local<Context> context = env->context();
+ Local<Object> issuer;
+
+ for (size_t i = snapshot->size(); i > snapshot_index + 1; i--) {
+ const size_t index = i - 1;
+ auto parsed = snapshot->Parse(index);
+ if (!parsed) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(
+ env, "Failed to parse X.509 certificate snapshot");
+ return {};
+ }
+
+ Local<Object> issuer_object;
+ if (!GetConstructorTemplate(env)
+ ->InstanceTemplate()
+ ->NewInstance(context)
+ .ToLocal(&issuer_object)) {
+ return {};
+ }
+ new X509Certificate(env,
+ issuer_object,
+ ManagedX509::Adopt(std::move(parsed)),
+ snapshot,
+ index,
+ issuer);
+ issuer = issuer_object;
+ }
+
Local<Object> obj;
if (!GetConstructorTemplate(env)
->InstanceTemplate()
- ->NewInstance(env->context())
+ ->NewInstance(context)
.ToLocal(&obj)) {
- return MaybeLocal<Object>();
+ return {};
}
+ new X509Certificate(
+ env, obj, std::move(cert), std::move(snapshot), snapshot_index, issuer);
+ return scope.Escape(obj);
+}
- Local<Object> issuer;
- if (issuer_chain != nullptr &&
- static_cast<OPENSSL_SIZE_T>(start) < sk_X509_num(issuer_chain)) {
- X509Pointer issuer_cert =
- X509View(sk_X509_value(issuer_chain, start)).clone();
- if (!issuer_cert) [[unlikely]] {
- return MaybeLocal<Object>();
- }
- if (!NewWithIssuers(env,
- std::make_shared<ManagedX509>(std::move(issuer_cert)),
- issuer_chain,
- start + 1)
- .ToLocal(&issuer)) [[unlikely]] {
- return MaybeLocal<Object>();
- }
+MaybeLocal<Object> X509Certificate::New(
+ Environment* env,
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index) {
+ if (!snapshot || snapshot_index >= snapshot->size()) return {};
+ auto cert = snapshot->Parse(snapshot_index);
+ if (!cert) [[unlikely]] {
+ THROW_ERR_CRYPTO_OPERATION_FAILED(
+ env, "Failed to parse X.509 certificate snapshot");
+ return {};
}
-
- new X509Certificate(env, obj, std::move(cert), issuer);
- return scope.Escape(obj);
+ return New(env,
+ ManagedX509::Adopt(std::move(cert)),
+ std::move(snapshot),
+ snapshot_index);
}
MaybeLocal<Object> X509Certificate::GetCert(Environment* env,
@@ -1064,26 +1208,25 @@ MaybeLocal<Object> X509Certificate::GetPeerCert(Environment* env,
// The peer chain is owned by the SSL session and must not be modified. Its
// first entry is the peer certificate on the client but not on the server.
const STACK_OF(X509)* ssl_certs = SSL_get_peer_cert_chain(ssl.get());
- int issuers_start = 0;
+ OPENSSL_SIZE_T issuers_start = 0;
X509Pointer cert;
if ((flag & GetPeerCertificateFlag::SERVER) ==
GetPeerCertificateFlag::SERVER) {
cert = X509Pointer::PeerFrom(ssl);
}
+
+ if (!cert && (ssl_certs == nullptr || sk_X509_num(ssl_certs) == 0))
+ return MaybeLocal<Object>();
+
if (!cert) {
- if (ssl_certs == nullptr || sk_X509_num(ssl_certs) == 0)
- return MaybeLocal<Object>();
- cert = X509View(sk_X509_value(ssl_certs, 0)).clone();
- if (!cert) [[unlikely]]
- return MaybeLocal<Object>();
+ const X509* leaf = sk_X509_value(ssl_certs, 0);
+ if (leaf != nullptr) cert = X509View(leaf).clone();
issuers_start = 1;
}
- return NewWithIssuers(env,
- std::make_shared<ManagedX509>(std::move(cert)),
- ssl_certs,
- issuers_start);
+ if (!cert) return {};
+ return New(env, std::move(cert), ssl_certs, issuers_start);
}
v8::MaybeLocal<v8::Value> X509Certificate::toObject(Environment* env) {
@@ -1097,11 +1240,17 @@ v8::MaybeLocal<v8::Value> X509Certificate::toObject(Environment* env,
return X509ToObject(env, cert).FromMaybe(Local<Value>());
}
-X509Certificate::X509Certificate(Environment* env,
- Local<Object> object,
- std::shared_ptr<ManagedX509> cert,
- Local<Object> issuer_chain)
- : BaseObject(env, object), cert_(std::move(cert)) {
+X509Certificate::X509Certificate(
+ Environment* env,
+ Local<Object> object,
+ std::shared_ptr<ManagedX509> cert,
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index,
+ Local<Object> issuer_chain)
+ : BaseObject(env, object),
+ cert_(std::move(cert)),
+ snapshot_(std::move(snapshot)),
+ snapshot_index_(snapshot_index) {
MakeWeak();
if (!issuer_chain.IsEmpty()) {
@@ -1111,6 +1260,12 @@ X509Certificate::X509Certificate(Environment* env,
void X509Certificate::MemoryInfo(MemoryTracker* tracker) const {
tracker->TrackField("cert", cert_);
+ tracker->TrackField("snapshot", snapshot_);
+}
+
+void X509Certificate::X509CertificateTransferData::MemoryInfo(
+ MemoryTracker* tracker) const {
+ tracker->TrackField("snapshot", snapshot_);
}
BaseObjectPtr<BaseObject>
@@ -1124,7 +1279,7 @@ X509Certificate::X509CertificateTransferData::Deserialize(
}
Local<Value> handle;
- if (!X509Certificate::New(env, data_).ToLocal(&handle))
+ if (!X509Certificate::New(env, snapshot_, snapshot_index_).ToLocal(&handle))
return {};
Local<Value> module =
@@ -1157,7 +1312,8 @@ BaseObject::TransferMode X509Certificate::GetTransferMode() const {
std::unique_ptr<worker::TransferData> X509Certificate::CloneForMessaging()
const {
- return std::make_unique<X509CertificateTransferData>(cert_);
+ return std::make_unique<X509CertificateTransferData>(snapshot_,
+ snapshot_index_);
}
void X509Certificate::Initialize(Environment* env, Local<Object> target) {
diff --git a/src/crypto/crypto_x509.h b/src/crypto/crypto_x509.h
index a6f64d4f2d4..44714d1bcca 100644
--- a/src/crypto/crypto_x509.h
+++ b/src/crypto/crypto_x509.h
@@ -11,22 +11,24 @@
#include "node_worker.h"
#include "v8.h"
+#include <memory>
+#include <optional>
+#include <vector>
+
namespace node {
namespace crypto {
-// The ManagedX509 class is essentially a smart pointer for
-// X509 objects that allows an X509Certificate instance to
-// be cloned at the JS level while pointing at the same
-// underlying X509 instance.
+// ManagedX509 owns one isolate-local X509. Adopt makes the ownership transfer
+// explicit at each call site.
class ManagedX509 final : public MemoryRetainer {
public:
- ManagedX509() = default;
- explicit ManagedX509(ncrypto::X509Pointer&& cert);
- ManagedX509(const ManagedX509& that);
- ManagedX509& operator=(const ManagedX509& that);
+ static std::shared_ptr<ManagedX509> Adopt(ncrypto::X509Pointer cert);
+
+ ManagedX509(const ManagedX509&) = delete;
+ ManagedX509& operator=(const ManagedX509&) = delete;
inline operator bool() const { return !!cert_; }
- inline X509* get() const { return cert_.get(); }
+ inline X509* get() { return cert_.get(); }
inline ncrypto::X509View view() const { return cert_; }
inline operator ncrypto::X509View() const { return cert_; }
@@ -35,9 +37,40 @@ class ManagedX509 final : public MemoryRetainer {
SET_SELF_SIZE(ManagedX509)
private:
+ explicit ManagedX509(ncrypto::X509Pointer cert);
+
ncrypto::X509Pointer cert_;
};
+// An immutable certificate-encoding snapshot. Certificate zero is the leaf and
+// all remaining certificates follow in the supplied order. Sharing this
+// snapshot between isolates is safe; the isolate-local X509 objects are always
+// reparsed from it.
+class X509CertificateSnapshot final : public MemoryRetainer {
+ public:
+ using DER = std::vector<unsigned char>;
+
+ static std::shared_ptr<const X509CertificateSnapshot> Create(
+ ncrypto::X509View leaf,
+ const STACK_OF(X509) * issuer_chain = nullptr,
+ OPENSSL_SIZE_T issuers_start = 0);
+
+ ncrypto::X509Pointer Parse(size_t index) const;
+
+ inline size_t size() const { return certificates_.size(); }
+ inline const DER& at(size_t index) const { return certificates_.at(index); }
+ size_t size_bytes() const;
+
+ void MemoryInfo(MemoryTracker* tracker) const override;
+ SET_MEMORY_INFO_NAME(X509CertificateSnapshot)
+ SET_SELF_SIZE(X509CertificateSnapshot)
+
+ private:
+ explicit X509CertificateSnapshot(std::vector<DER> certificates);
+
+ const std::vector<DER> certificates_;
+};
+
class X509Certificate final : public BaseObject {
public:
enum class GetPeerCertificateFlag {
@@ -60,12 +93,19 @@ class X509Certificate final : public BaseObject {
static v8::MaybeLocal<v8::Object> New(
Environment* env,
ncrypto::X509Pointer cert,
- const STACK_OF(X509) * issuer_chain = nullptr);
+ const STACK_OF(X509) * issuer_chain = nullptr,
+ OPENSSL_SIZE_T issuers_start = 0);
static v8::MaybeLocal<v8::Object> New(
Environment* env,
std::shared_ptr<ManagedX509> cert,
- const STACK_OF(X509) * issuer_chain = nullptr);
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index = 0);
+
+ static v8::MaybeLocal<v8::Object> New(
+ Environment* env,
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index = 0);
static v8::MaybeLocal<v8::Object> GetCert(Environment* env,
const ncrypto::SSLPointer& ssl);
@@ -96,20 +136,22 @@ class X509Certificate final : public BaseObject {
class X509CertificateTransferData : public worker::TransferData {
public:
explicit X509CertificateTransferData(
- const std::shared_ptr<ManagedX509>& data)
- : data_(data) {}
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index)
+ : snapshot_(std::move(snapshot)), snapshot_index_(snapshot_index) {}
BaseObjectPtr<BaseObject> Deserialize(
Environment* env,
v8::Local<v8::Context> context,
std::unique_ptr<worker::TransferData> self) override;
+ void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(X509CertificateTransferData)
SET_SELF_SIZE(X509CertificateTransferData)
- SET_NO_MEMORY_INFO()
private:
- std::shared_ptr<ManagedX509> data_;
+ std::shared_ptr<const X509CertificateSnapshot> snapshot_;
+ size_t snapshot_index_;
};
BaseObject::TransferMode GetTransferMode() const override;
@@ -119,16 +161,13 @@ class X509Certificate final : public BaseObject {
X509Certificate(Environment* env,
v8::Local<v8::Object> object,
std::shared_ptr<ManagedX509> cert,
+ std::shared_ptr<const X509CertificateSnapshot> snapshot,
+ size_t snapshot_index,
v8::Local<v8::Object> issuer_chain = v8::Local<v8::Object>());
- // Like New(), but reads the issuer chain from issuer_chain[start] upward.
- static v8::MaybeLocal<v8::Object> NewWithIssuers(
- Environment* env,
- std::shared_ptr<ManagedX509> cert,
- const STACK_OF(X509) * issuer_chain,
- int start);
-
std::shared_ptr<ManagedX509> cert_;
+ std::shared_ptr<const X509CertificateSnapshot> snapshot_;
+ size_t snapshot_index_ = 0;
BaseObjectPtr<X509Certificate> issuer_cert_;
};
diff --git a/test/parallel/test-crypto-x509-chain-snapshot.js b/test/parallel/test-crypto-x509-chain-snapshot.js
new file mode 100644
index 00000000000..5a0f696da0f
--- /dev/null
+++ b/test/parallel/test-crypto-x509-chain-snapshot.js
@@ -0,0 +1,95 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('node:assert');
+const { X509Certificate } = require('node:crypto');
+const { once } = require('node:events');
+const tls = require('node:tls');
+const {
+ MessageChannel,
+ Worker,
+} = require('node:worker_threads');
+const fixtures = require('../common/fixtures');
+
+const leaf = new X509Certificate(fixtures.readKey('agent6-cert.pem'));
+const issuer = new X509Certificate(fixtures.readKey('ca3-cert.pem'));
+
+function assertChain(actual) {
+ assert(actual instanceof X509Certificate);
+ assert.deepStrictEqual(actual.raw, leaf.raw);
+ assert.strictEqual(actual.issuerCertificate, actual.issuerCertificate);
+ assert(actual.issuerCertificate instanceof X509Certificate);
+ assert.deepStrictEqual(actual.issuerCertificate.raw, issuer.raw);
+ assert.strictEqual(actual.issuerCertificate.issuerCertificate, undefined);
+}
+
+function legacyChainRaw(certificate) {
+ const result = [];
+ const seen = new Set();
+ while (certificate !== undefined && !seen.has(certificate)) {
+ seen.add(certificate);
+ result.push(certificate.raw);
+ certificate = certificate.issuerCertificate;
+ }
+ return result;
+}
+
+async function cloneViaMessagePort(certificate) {
+ const { port1, port2 } = new MessageChannel();
+ port1.postMessage(certificate);
+ const [clone] = await once(port2, 'message');
+ port1.close();
+ port2.close();
+ return clone;
+}
+
+async function cloneViaWorker(certificate) {
+ const worker = new Worker(`
+ 'use strict';
+ const { parentPort } = require('node:worker_threads');
+ parentPort.once('message', (certificate) => {
+ parentPort.postMessage(certificate);
+ });
+ `, { eval: true });
+ worker.postMessage(certificate);
+ const [clone] = await once(worker, 'message');
+ await worker.terminate();
+ return clone;
+}
+
+const server = tls.createServer({
+ key: fixtures.readKey('agent6-key.pem'),
+ cert: fixtures.readKey('agent6-cert.pem'),
+}, (socket) => socket.end());
+
+(async () => {
+ server.listen(0);
+ await once(server, 'listening');
+
+ const socket = tls.connect({
+ port: server.address().port,
+ rejectUnauthorized: false,
+ });
+ await once(socket, 'secureConnect');
+
+ const first = socket.getPeerX509Certificate();
+ assertChain(first);
+
+ const firstLegacy = socket.getPeerCertificate(true);
+ const second = socket.getPeerX509Certificate();
+ const secondLegacy = socket.getPeerCertificate(true);
+ assertChain(second);
+ assert.deepStrictEqual(legacyChainRaw(firstLegacy), [leaf.raw, issuer.raw]);
+ assert.deepStrictEqual(legacyChainRaw(secondLegacy), [leaf.raw, issuer.raw]);
+
+ assertChain(structuredClone(first));
+ assertChain(await cloneViaMessagePort(first));
+ assertChain(await cloneViaWorker(first));
+
+ socket.destroy();
+ server.close();
+ await once(server, 'close');
+})().then(common.mustCall());
diff --git a/test/parallel/test-crypto-x509-peer-chain.js b/test/parallel/test-crypto-x509-peer-chain.js
new file mode 100644
index 00000000000..b60f3d00acc
--- /dev/null
+++ b/test/parallel/test-crypto-x509-peer-chain.js
@@ -0,0 +1,110 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+ common.skip('missing crypto');
+
+const assert = require('node:assert');
+const { X509Certificate } = require('node:crypto');
+const { once } = require('node:events');
+const tls = require('node:tls');
+const { MessageChannel, Worker } = require('node:worker_threads');
+const fixtures = require('../common/fixtures');
+
+const leaf = new X509Certificate(fixtures.readKey('agent6-cert.pem'));
+const issuer = new X509Certificate(fixtures.readKey('ca3-cert.pem'));
+const unrelated = new X509Certificate(fixtures.readKey('agent1-cert.pem'));
+const key = fixtures.readKey('agent6-key.pem');
+const chains = [
+ [leaf, issuer, unrelated],
+ [leaf, unrelated, issuer],
+ [leaf, issuer, issuer],
+ [leaf, issuer, unrelated, issuer],
+ [leaf, leaf, issuer],
+];
+
+function assertChain(actual, expected) {
+ const seen = new Set();
+ for (const certificate of expected) {
+ assert(actual instanceof X509Certificate);
+ assert(!seen.has(actual));
+ seen.add(actual);
+ assert.deepStrictEqual(actual.raw, certificate.raw);
+ assert.strictEqual(actual.issuerCertificate, actual.issuerCertificate);
+ actual = actual.issuerCertificate;
+ }
+ assert.strictEqual(actual, undefined);
+}
+
+async function assertClones(certificate, chain) {
+ assertChain(structuredClone(certificate), chain);
+
+ const { port1, port2 } = new MessageChannel();
+ port1.postMessage(certificate);
+ const [messageClone] = await once(port2, 'message');
+ port1.close();
+ port2.close();
+ assertChain(messageClone, chain);
+
+ const worker = new Worker(`
+ const { parentPort } = require('node:worker_threads');
+ parentPort.once('message', (certificate) => {
+ parentPort.postMessage(certificate);
+ });
+ `, { eval: true });
+ worker.postMessage(certificate);
+ const [workerClone] = await once(worker, 'message');
+ await worker.terminate();
+ assertChain(workerClone, chain);
+}
+
+async function checkPeer(socket, chain) {
+ const first = socket.getPeerX509Certificate();
+ assertChain(first, chain);
+ for (let i = 0; i < 2; i++) {
+ assert.deepStrictEqual(socket.getPeerCertificate(true).raw, leaf.raw);
+ assertChain(socket.getPeerX509Certificate(), chain);
+ }
+ await assertClones(first, chain);
+}
+
+async function test(chain) {
+ // Encode each certificate separately: agent6-cert.pem contains its issuer.
+ const cert = chain.map((certificate) => certificate.toString()).join('');
+ const server = tls.createServer({
+ key,
+ cert,
+ requestCert: true,
+ rejectUnauthorized: false,
+ });
+ server.listen(0);
+ await once(server, 'listening');
+
+ const peer = once(server, 'secureConnection');
+ const client = tls.connect({
+ port: server.address().port,
+ key,
+ cert,
+ rejectUnauthorized: false,
+ });
+ await once(client, 'secureConnect');
+ const [socket] = await peer;
+
+ // Client and server certificate stacks handle the leaf differently; both
+ // sockets must expose every certificate sent by the peer exactly once.
+ await checkPeer(client, chain);
+ await checkPeer(socket, chain);
+
+ // Clones must remain usable after their TLS session has been destroyed.
+ const clone = structuredClone(client.getPeerX509Certificate());
+ client.destroy();
+ socket.destroy();
+ const closed = once(server, 'close');
+ server.close();
+ await closed;
+ assertChain(clone, chain);
+}
+
+(async () => {
+ for (const chain of chains) await test(chain);
+})().then(common.mustCall());