From b1cb69ec6777e85ba61f69f3101b9331ef442490 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:56:43 +0000 Subject: [PATCH 1/3] node:crypto: build X509Certificate validFromDate / validToDate from the ASN.1 time The two getters printed the time as text ("Dec 31 23:59:59 30 GMT") and parsed that text back with WTF::parseDate. The two-digit-year rule of the parser moved the years 0000-0099 to 1950-2049, so a certificate that expired in the year 30 reported validToDate = 2030-12-31. Convert the ASN1_TIME to POSIX seconds with BoringSSL and build the Date from that, as Node does (ncrypto X509View::getValidFromTime / getValidToTime). ASN1_TIME_to_posix_nonstandard also applies a "+hhmm" offset on a UTCTime. The X509 parser accepts that form and the printed text for it is "Bad time value", so the getters returned Invalid Date where Node returns the instant. --- .../bindings/JSX509CertificatePrototype.cpp | 31 +++------ src/jsc/bindings/ncrypto.cpp | 22 ++++++ src/jsc/bindings/ncrypto.h | 3 + test/js/node/crypto/x509.test.ts | 69 +++++++++++++++++++ 4 files changed, 104 insertions(+), 21 deletions(-) diff --git a/src/jsc/bindings/JSX509CertificatePrototype.cpp b/src/jsc/bindings/JSX509CertificatePrototype.cpp index 65fe901f9c9a..f06b6e2a2c1d 100644 --- a/src/jsc/bindings/JSX509CertificatePrototype.cpp +++ b/src/jsc/bindings/JSX509CertificatePrototype.cpp @@ -18,7 +18,6 @@ #include "webcrypto/CryptoKeyRSA.h" #include "webcrypto/CryptoKeyOKP.h" #include "webcrypto/CryptoKeyAES.h" -#include "wtf/DateMath.h" #include "AsymmetricKeyValue.h" #include #include "JSKeyObject.h" @@ -404,6 +403,14 @@ static JSValue undefinedIfEmpty(JSUint8Array* value) return value; } +// Built from the ASN.1 time, not from the validFrom / validTo text: that prints +// the year 30 as "30", which a date parser reads as 2030. +static JSValue validityDate(VM& vm, JSGlobalObject* globalObject, std::optional seconds) +{ + double ms = seconds ? static_cast(*seconds) * 1000.0 : PNaN; + return DateInstance::create(vm, globalObject->dateStructure(), ms); +} + JSC_DEFINE_HOST_FUNCTION(jsX509CertificateProtoFuncVerify, (JSGlobalObject * globalObject, CallFrame* callFrame)) { VM& vm = globalObject->vm(); @@ -719,16 +726,7 @@ JSC_DEFINE_CUSTOM_GETTER(jsX509CertificateGetter_validToDate, (JSGlobalObject * return {}; } - auto* validToDate = thisObject->validTo(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - auto view = validToDate->view(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - Bun::UTF8View validToDateView = Bun::UTF8View(view); - if (view->isEmpty()) - return JSValue::encode(jsUndefined()); - std::span span = { reinterpret_cast(validToDateView.span().data()), validToDateView.span().size() }; - double date = WTF::parseDate(span); - return JSValue::encode(JSC::DateInstance::create(vm, globalObject->dateStructure(), date)); + return JSValue::encode(validityDate(vm, globalObject, thisObject->view().getValidToTime())); } JSC_DEFINE_CUSTOM_GETTER(jsX509CertificateGetter_validFromDate, (JSGlobalObject * globalObject, EncodedJSValue thisValue, PropertyName)) @@ -742,15 +740,6 @@ JSC_DEFINE_CUSTOM_GETTER(jsX509CertificateGetter_validFromDate, (JSGlobalObject return {}; } - auto* validFromDate = thisObject->validFrom(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - auto view = validFromDate->view(globalObject); - RETURN_IF_EXCEPTION(scope, {}); - Bun::UTF8View validFromDateView = Bun::UTF8View(view); - if (view->isEmpty()) - return JSValue::encode(jsUndefined()); - std::span span = { reinterpret_cast(validFromDateView.span().data()), validFromDateView.span().size() }; - double date = WTF::parseDate(span); - return JSValue::encode(JSC::DateInstance::create(vm, globalObject->dateStructure(), date)); + return JSValue::encode(validityDate(vm, globalObject, thisObject->view().getValidFromTime())); } } // namespace Bun diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 8db1ad017fcb..84d2385ef20c 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -998,6 +998,28 @@ std::optional X509View::getSignatureAlgorithmOID() const return std::string(buf, static_cast(len)); } +// BoringSSL's X509 parser accepts a UTCTime with a "+hhmm" / "-hhmm" offset, +// which ASN1_TIME_to_posix refuses. OpenSSL's ASN1_TIME_to_tm, which Node +// uses, applies the offset, and so does the _nonstandard variant. +static std::optional asn1TimeToPosix(const ASN1_TIME* time) +{ + int64_t seconds; + if (time == nullptr || !ASN1_TIME_to_posix_nonstandard(time, &seconds)) return std::nullopt; + return seconds; +} + +std::optional X509View::getValidFromTime() const +{ + if (cert_ == nullptr) return std::nullopt; + return asn1TimeToPosix(X509_get0_notBefore(cert_)); +} + +std::optional X509View::getValidToTime() const +{ + if (cert_ == nullptr) return std::nullopt; + return asn1TimeToPosix(X509_get0_notAfter(cert_)); +} + DataPointer X509View::getSerialNumber() const { ClearErrorOnReturn clearErrorOnReturn; diff --git a/src/jsc/bindings/ncrypto.h b/src/jsc/bindings/ncrypto.h index 3d2c8fe13a73..f534f012bbd1 100644 --- a/src/jsc/bindings/ncrypto.h +++ b/src/jsc/bindings/ncrypto.h @@ -958,6 +958,9 @@ class X509View final { BIOPointer getValidTo() const; std::optional getSignatureAlgorithm() const; std::optional getSignatureAlgorithmOID() const; + // Seconds since the Unix epoch. + std::optional getValidFromTime() const; + std::optional getValidToTime() const; DataPointer getSerialNumber() const; Result getPublicKey() const; StackOfASN1 getKeyUsage() const; diff --git a/test/js/node/crypto/x509.test.ts b/test/js/node/crypto/x509.test.ts index a704de27e7d8..4f810d7abb77 100644 --- a/test/js/node/crypto/x509.test.ts +++ b/test/js/node/crypto/x509.test.ts @@ -161,6 +161,75 @@ describe("X509Certificate.prototype property descriptors", () => { }); }); +describe("X509Certificate validFromDate / validToDate", () => { + // Self-signed, CN=localhost. Both times are GeneralizedTime, so another four-digit year can be + // written over them without changing any DER length. That breaks the signature, which + // X509Certificate does not check. + const notBefore = "00200101000000Z"; + const notAfter = "00751231235959Z"; + const templatePem = `-----BEGIN CERTIFICATE----- +MIIBPTCB46ADAgECAgIQADAKBggqhkjOPQQDAjAUMRIwEAYDVQQDDAlsb2NhbGhv +c3QwIhgPMDAyMDAxMDEwMDAwMDBaGA8wMDc1MTIzMTIzNTk1OVowFDESMBAGA1UE +AwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJC2RuReWuXPw +IXWc22JNB/Jwkm4uGiaO1cRhQh6gF7kTLflBzbp6vAnZIB3ukIRPh1syrqVuEPuB +ugLCAXxsoqMhMB8wHQYDVR0OBBYEFDYYVTNtjOn8v4bS9+yhJX5DS0A1MAoGCCqG +SM49BAMCA0kAMEYCIQDgLDP/kZnwdTEpvTsMQKhLWg4NeiH62qkajleA8tWF8QIh +AJSCZhtcwld0QcIt8+FsNj13xry7oItjmw9/n1dicxIW +-----END CERTIFICATE-----`; + + // The same certificate with the validity rewritten as UTCTime "200101000000-0530" and + // "301231235959+0100". RFC 5280 forbids the offset, but BoringSSL and OpenSSL both parse it. + const utcOffsetPem = `-----BEGIN CERTIFICATE----- +MIIBQTCB56ADAgECAgIQADAKBggqhkjOPQQDAjAUMRIwEAYDVQQDDAlsb2NhbGhv +c3QwJhcRMjAwMTAxMDAwMDAwLTA1MzAXETMwMTIzMTIzNTk1OSswMTAwMBQxEjAQ +BgNVBAMMCWxvY2FsaG9zdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCQtkbkX +lrlz8CF1nNtiTQfycJJuLhomjtXEYUIeoBe5Ey35Qc26erwJ2SAd7pCET4dbMq6l +bhD7gboCwgF8bKKjITAfMB0GA1UdDgQWBBQ2GFUzbYzp/L+G0vfsoSV+Q0tANTAK +BggqhkjOPQQDAgNJADBGAiEA4Cwz/5GZ8HUxKb07DECoS1oODXoh+tqpGo5XgPLV +hfECIQCUgmYbXMJXdEHCLfPhbDY9d8a8u6CLY5sPf59XYnMSFg== +-----END CERTIFICATE-----`; + + function certForYear(year: string) { + const der = Buffer.from(new X509Certificate(templatePem).raw); + der.write(year, der.indexOf(notBefore), "latin1"); + der.write(year, der.indexOf(notAfter), "latin1"); + return new X509Certificate(der); + } + + // The printed year "30" used to be parsed back as 2030, so a certificate that expired in the + // year 30 looked valid to `cert.validToDate > new Date()`. + test("a year below 100 is not moved to 1950-2049", () => { + const cert = new X509Certificate(templatePem); + expect({ + validFrom: cert.validFrom, + validFromDate: cert.validFromDate.toISOString(), + validTo: cert.validTo, + validToDate: cert.validToDate.toISOString(), + }).toEqual({ + validFrom: "Jan 1 00:00:00 20 GMT", + validFromDate: "0020-01-01T00:00:00.000Z", + validTo: "Dec 31 23:59:59 75 GMT", + validToDate: "0075-12-31T23:59:59.000Z", + }); + expect(certForYear("0030").validToDate < new Date()).toBe(true); + }); + + test.each(["0000", "0049", "0050", "0099", "0100", "1601", "1969", "1970", "2038", "2049", "2050", "9999"])( + "GeneralizedTime year %s", + year => { + const cert = certForYear(year); + expect(cert.validFromDate.toISOString()).toBe(`${year}-01-01T00:00:00.000Z`); + expect(cert.validToDate.toISOString()).toBe(`${year}-12-31T23:59:59.000Z`); + }, + ); + + test("a UTCTime timezone offset is applied", () => { + const cert = new X509Certificate(utcOffsetPem); + expect(cert.validFromDate.toISOString()).toBe("2020-01-01T05:30:00.000Z"); + expect(cert.validToDate.toISOString()).toBe("2030-12-31T22:59:59.000Z"); + }); +}); + describe("X509Certificate with an empty subject/issuer DN", () => { // Self-signed, `openssl req -x509 -subj "/"`: both names are empty sequences. const emptyDN = `-----BEGIN CERTIFICATE----- From 55e09771682ab68b5b4185721a7833923000eb8f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:33:51 +0000 Subject: [PATCH 2/3] ci: retrigger From 13fbb75898b2e36e0d032368a2a6b1e7c7b5a138 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:36:21 +0000 Subject: [PATCH 3/3] Trim the comments on the validity time conversion --- src/jsc/bindings/JSX509CertificatePrototype.cpp | 2 -- src/jsc/bindings/ncrypto.cpp | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/jsc/bindings/JSX509CertificatePrototype.cpp b/src/jsc/bindings/JSX509CertificatePrototype.cpp index f06b6e2a2c1d..3d317bf64206 100644 --- a/src/jsc/bindings/JSX509CertificatePrototype.cpp +++ b/src/jsc/bindings/JSX509CertificatePrototype.cpp @@ -403,8 +403,6 @@ static JSValue undefinedIfEmpty(JSUint8Array* value) return value; } -// Built from the ASN.1 time, not from the validFrom / validTo text: that prints -// the year 30 as "30", which a date parser reads as 2030. static JSValue validityDate(VM& vm, JSGlobalObject* globalObject, std::optional seconds) { double ms = seconds ? static_cast(*seconds) * 1000.0 : PNaN; diff --git a/src/jsc/bindings/ncrypto.cpp b/src/jsc/bindings/ncrypto.cpp index 84d2385ef20c..e1781a34ceb6 100644 --- a/src/jsc/bindings/ncrypto.cpp +++ b/src/jsc/bindings/ncrypto.cpp @@ -998,12 +998,10 @@ std::optional X509View::getSignatureAlgorithmOID() const return std::string(buf, static_cast(len)); } -// BoringSSL's X509 parser accepts a UTCTime with a "+hhmm" / "-hhmm" offset, -// which ASN1_TIME_to_posix refuses. OpenSSL's ASN1_TIME_to_tm, which Node -// uses, applies the offset, and so does the _nonstandard variant. static std::optional asn1TimeToPosix(const ASN1_TIME* time) { int64_t seconds; + // _nonstandard: the X509 parser accepts a UTCTime "+hhmm" offset, and Node (OpenSSL) applies it. if (time == nullptr || !ASN1_TIME_to_posix_nonstandard(time, &seconds)) return std::nullopt; return seconds; }