From 586410fd19d199862f7daee1237d7720d2ad35e3 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 30 Aug 2026 09:08:31 +0200 Subject: [PATCH] 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 --- lib/internal/crypto/x509.js | 16 ++++ lib/v8.js | 58 +++++++++++- src/crypto/crypto_x509.cc | 92 +++++++++++++++++++ src/crypto/crypto_x509.h | 4 + .../test-crypto-x509-chain-key-usage.js | 87 ++++++++++++++++++ .../test-crypto-x509-chain-snapshot.js | 2 + test/parallel/test-crypto-x509-peer-chain.js | 2 + test/parallel/test-v8-serdes.js | 11 +++ typings/internalBinding/crypto.d.ts | 6 ++ 9 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-crypto-x509-chain-key-usage.js diff --git a/lib/internal/crypto/x509.js b/lib/internal/crypto/x509.js index cf1de4474756..0a0da0296a84 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 ea3366bb9617..6ae1d2c8ee65 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 3c208082f944..3a9c296970a5 100644 --- a/src/crypto/crypto_x509.cc +++ b/src/crypto/crypto_x509.cc @@ -128,6 +128,23 @@ std::shared_ptr X509CertificateSnapshot::Create( new X509CertificateSnapshot(std::move(certificates))); } +std::shared_ptr X509CertificateSnapshot::Create( + std::vector 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( + 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& args) { issuer); } +void X509Certificate::ParseSnapshot(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Local 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 input = args[0].As(); + std::vector certificates; + certificates.reserve(input->Length()); + for (uint32_t i = 0; i < input->Length(); i++) { + Local 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 contents( + value.As()); + 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 cert; + if (New(env, std::move(snapshot)).ToLocal(&cert)) { + args.GetReturnValue().Set(cert); + } +} + +void X509Certificate::GetSnapshot(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + Local 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(args[0].As()); + 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(std::numeric_limits::max())); + Local output = Array::New(env->isolate(), static_cast(count)); + for (size_t i = 0; i < count; i++) { + const auto& der = cert->snapshot_->at(cert->snapshot_index_ + i); + Local buffer; + if (!Buffer::Copy( + env, reinterpret_cast(der.data()), der.size()) + .ToLocal(&buffer) || + !output->CreateDataProperty(context, static_cast(i), buffer) + .FromMaybe(false)) { + return; + } + } + args.GetReturnValue().Set(output); +} + void X509Certificate::CreateX509CertificateClass( const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); @@ -1324,6 +1411,9 @@ void X509Certificate::Initialize(Environment* env, Local 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 44714d1bcca2..e618edd8e5a0 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 Create( + std::vector certificates); ncrypto::X509Pointer Parse(size_t index) const; @@ -89,6 +91,8 @@ class X509Certificate final : public BaseObject { static void IsX509Certificate( const v8::FunctionCallbackInfo& args); static void NewFromHandle(const v8::FunctionCallbackInfo& args); + static void ParseSnapshot(const v8::FunctionCallbackInfo& args); + static void GetSnapshot(const v8::FunctionCallbackInfo& args); static v8::MaybeLocal 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 000000000000..100e2dbb9e5e --- /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 5a0f696da0f1..433abd9fccab 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 b60f3d00acc0..d14df8eda922 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 2c7a20b24f96..0c7af6d78eba 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 3d5a9fceb2e6..b794f51bdd37 100644 --- a/typings/internalBinding/crypto.d.ts +++ b/typings/internalBinding/crypto.d.ts @@ -971,6 +971,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; @@ -996,6 +999,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;