diff --git a/patches/boringssl/self-signed-anchor-without-keycertsign.patch b/patches/boringssl/self-signed-anchor-without-keycertsign.patch new file mode 100644 index 000000000000..5cb96380b7a0 --- /dev/null +++ b/patches/boringssl/self-signed-anchor-without-keycertsign.patch @@ -0,0 +1,319 @@ +x509: trust a self-signed anchor whose keyUsage lacks keyCertSign + +Path building matched candidate issuers with X509_check_issued, which +rejects any issuer whose keyUsage extension lacks keyCertSign. A +self-signed end-entity certificate (keyUsage = digitalSignature, +keyEncipherment, the shape of `dotnet dev-certs` and `openssl req -x509` +server certificates) therefore never matched the copy of itself in the +trust store, and did not get EXFLAG_SS either. Verification failed with +X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY even though the operator +pinned that exact certificate. OpenSSL 3 trusts it. + +Split X509_check_issued the way OpenSSL 3 does: + +- x509_likely_issued: subject/issuer name and authority key identifier. + Path building (x509_check_issued_with_callback, and so find_issuer, + X509_STORE_CTX_get1_issuer and the self-signed checks) uses this. +- x509_signing_allowed: the keyCertSign check. internal_verify applies + it on every issuer edge of the final chain (RFC 5280, 6.1.4 (n)) and + reports X509_V_ERR_KEYUSAGE_NO_CERTSIGN with the issuer as the current + certificate. A self-signed certificate is its own issuer, not an edge, + so it is exempt, as in OpenSSL 3. +- EXFLAG_SS no longer requires keyCertSign. +- X509_check_issued keeps both checks for external callers. + +Visible changes: a pinned self-signed certificate without keyCertSign +verifies. The unpinned one reports DEPTH_ZERO_SELF_SIGNED_CERT instead +of UNABLE_TO_GET_ISSUER_CERT_LOCALLY. An untrusted intermediate without +keyCertSign now reaches check_chain_extensions and fails with +INVALID_CA. A trusted issuer without keyCertSign fails with +KEYUSAGE_NO_CERTSIGN. All three match OpenSSL 3. + +Upstream: oven-sh/boringssl, on top of 41bf9b59c2ebf277a7aa427e1ecad5cc80dd4d4f. + +--- a/crypto/x509/internal.h ++++ b/crypto/x509/internal.h +@@ -572,7 +572,22 @@ int X509_policy_check(const STACK_OF(X509) *certs, + const STACK_OF(ASN1_OBJECT) *user_policies, + unsigned long flags, X509 **out_current_cert); + +-// x509_check_issued_with_callback calls `X509_check_issued`, but allows the ++// x509_likely_issued checks if `issuer` and `subject`'s name and authority key ++// identifier fields allow `issuer` to have issued `subject`. It returns ++// `X509_V_OK` on success and an `X509_V_ERR_*` value otherwise. Unlike ++// `X509_check_issued`, it does not look at `issuer`'s key usage. Path building ++// uses it to pick candidate issuers, so a self-signed certificate without the ++// keyCertSign bit still matches an exact copy of itself in the trust store, as ++// in OpenSSL 3. Whether `issuer` may sign is checked separately by ++// `x509_signing_allowed` on every issuer edge of the final chain. ++int x509_likely_issued(const X509 *issuer, const X509 *subject); ++ ++// x509_signing_allowed returns `X509_V_OK` if `issuer`'s key usage extension, ++// when present, allows it to sign certificates, and ++// `X509_V_ERR_KEYUSAGE_NO_CERTSIGN` otherwise. ++int x509_signing_allowed(const X509 *issuer); ++ ++// x509_check_issued_with_callback calls `x509_likely_issued`, but allows the + // verify callback to override the result. It returns one on success and zero on + // error. + // +--- a/crypto/x509/v3_purp.cc ++++ b/crypto/x509/v3_purp.cc +@@ -313,9 +313,13 @@ int bssl::x509v3_cache_extensions(X509 *x) { + // Does subject name match issuer ? + if (!X509_NAME_cmp(X509_get_subject_name(x), X509_get_issuer_name(x))) { + impl->ex_flags |= EXFLAG_SI; +- // If SKID matches AKID also indicate self signed +- if (X509_check_akid(x, impl->akid.get()) == X509_V_OK && +- !ku_reject(x, X509v3_KU_KEY_CERT_SIGN)) { ++ // If SKID matches AKID also indicate self signed. As in OpenSSL 3, the key ++ // usage extension does not factor in: a self-signed end-entity certificate ++ // (keyUsage without keyCertSign) is still self-signed, so an exact copy of ++ // it in the trust store can anchor its own chain. Whether a certificate ++ // may sign another one is checked on each issuer edge by the path ++ // verifier, see `x509_signing_allowed`. ++ if (X509_check_akid(x, impl->akid.get()) == X509_V_OK) { + impl->ex_flags |= EXFLAG_SS; + } + } +@@ -469,7 +473,7 @@ static int check_purpose_timestamp_sign(const X509_PURPOSE *xp, const X509 *x, + + static int no_check(const X509_PURPOSE *xp, const X509 *x, int ca) { return 1; } + +-int X509_check_issued(const X509 *issuer, const X509 *subject) { ++int bssl::x509_likely_issued(const X509 *issuer, const X509 *subject) { + if (X509_NAME_cmp(X509_get_subject_name(issuer), + X509_get_issuer_name(subject))) { + return X509_V_ERR_SUBJECT_ISSUER_MISMATCH; +@@ -481,18 +485,29 @@ int X509_check_issued(const X509 *issuer, const X509 *subject) { + + const auto *subject_impl = FromOpaque(subject); + if (subject_impl->akid) { +- int ret = X509_check_akid(issuer, subject_impl->akid.get()); +- if (ret != X509_V_OK) { +- return ret; +- } ++ return X509_check_akid(issuer, subject_impl->akid.get()); + } ++ return X509_V_OK; ++} + ++int bssl::x509_signing_allowed(const X509 *issuer) { ++ if (!x509v3_cache_extensions(const_cast(issuer))) { ++ return X509_V_ERR_UNSPECIFIED; ++ } + if (ku_reject(issuer, X509v3_KU_KEY_CERT_SIGN)) { + return X509_V_ERR_KEYUSAGE_NO_CERTSIGN; + } + return X509_V_OK; + } + ++int X509_check_issued(const X509 *issuer, const X509 *subject) { ++ int ret = x509_likely_issued(issuer, subject); ++ if (ret != X509_V_OK) { ++ return ret; ++ } ++ return x509_signing_allowed(issuer); ++} ++ + int bssl::X509_check_akid(const X509 *issuer, const AUTHORITY_KEYID *akid) { + if (!akid) { + return X509_V_OK; +--- a/crypto/x509/x509_test.cc ++++ b/crypto/x509/x509_test.cc +@@ -8988,9 +8988,9 @@ TEST(X509Test, Purpose) { + ASSERT_TRUE(leaf); + ASSERT_TRUE(X509_sign(leaf.get(), key.get(), EVP_sha256())); + +- // The intermediate must have the keyCertSign bit. This bit is checked in +- // multiple places. The first place that fails is in looking for candidate +- // issuers. ++ // The intermediate must have the keyCertSign bit. Candidate issuers are ++ // matched without it, so the chain is built and the intermediate then ++ // fails the CA check in `check_chain_extensions`. + intermediate = + MakeTestCert("Root", "Intermediate", key.get(), /*is_ca=*/true); + ASSERT_TRUE(intermediate); +@@ -8999,7 +8999,7 @@ TEST(X509Test, Purpose) { + ASSERT_TRUE(AddExtendedKeyUsage(intermediate.get(), {t.eku_nid})); + } + ASSERT_TRUE(X509_sign(intermediate.get(), key.get(), EVP_sha256())); +- EXPECT_EQ(X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY, ++ EXPECT_EQ(X509_V_ERR_INVALID_CA, + Verify(leaf.get(), {root.get()}, {intermediate.get()}, {}, 0, + configure_callback)); + +@@ -11790,6 +11790,122 @@ static int VerifyWithStore(X509 *leaf, X509_STORE *store, + return X509_V_OK; + } + ++// A self-signed certificate whose keyUsage lacks keyCertSign (the shape of a ++// `dotnet dev-certs` or `openssl req -x509` server certificate) verifies when ++// an exact copy of it is a trust anchor, as in OpenSSL 3. Key usage still ++// rejects it as the issuer of any other certificate. ++TEST(X509Test, SelfSignedAnchorWithoutKeyCertSign) { ++ UniquePtr key = PrivateKeyFromPEM(kP256Key); ++ ASSERT_TRUE(key); ++ ++ auto set_server_purpose = [](X509_STORE_CTX *ctx) { ++ X509_STORE_CTX_set_purpose(ctx, X509_PURPOSE_SSL_SERVER); ++ }; ++ ++ auto make_self_signed = [&](bool is_ca, ++ std::optional> usages) { ++ UniquePtr cert = MakeTestCert("Leaf", "Leaf", key.get(), is_ca); ++ if (cert == nullptr || ++ !AddExtendedKeyUsage(cert.get(), {NID_server_auth}) || ++ (usages.has_value() && !AddKeyUsage(cert.get(), *usages)) || ++ !X509_sign(cert.get(), key.get(), EVP_sha256())) { ++ return UniquePtr(); ++ } ++ return cert; ++ }; ++ ++ const std::vector kNoCertSign = {KeyUsage::kDigitalSignature, ++ KeyUsage::kKeyEncipherment}; ++ const std::vector kCertSign = {KeyUsage::kDigitalSignature, ++ KeyUsage::kKeyCertSign}; ++ struct { ++ bool is_ca; ++ std::optional> usages; ++ } kShapes[] = { ++ {false, std::nullopt}, {false, kCertSign}, {false, kNoCertSign}, ++ {true, std::nullopt}, {true, kCertSign}, {true, kNoCertSign}, ++ }; ++ for (const auto &shape : kShapes) { ++ SCOPED_TRACE(shape.is_ca); ++ SCOPED_TRACE(shape.usages.has_value()); ++ UniquePtr cert = make_self_signed(shape.is_ca, shape.usages); ++ ASSERT_TRUE(cert); ++ EXPECT_TRUE(X509_get_extension_flags(cert.get()) & EXFLAG_SS); ++ ++ // Pinned as a trusted stack entry and as a store entry. ++ EXPECT_EQ(X509_V_OK, Verify(cert.get(), {cert.get()}, {}, {}, 0, ++ set_server_purpose)); ++ UniquePtr store(X509_STORE_new()); ++ ASSERT_TRUE(store); ++ ASSERT_TRUE(X509_STORE_add_cert(store.get(), cert.get())); ++ EXPECT_EQ(X509_V_OK, VerifyWithStore(cert.get(), store.get(), {})); ++ ++ // Not pinned, it is an untrusted self-signed certificate, not a ++ // certificate with no issuer. ++ EXPECT_EQ(X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT, ++ Verify(cert.get(), {}, {}, {}, 0, set_server_purpose)); ++ ++ // Only an exact copy anchors it. A second self-signed certificate with ++ // the same subject and key usage is not the pinned one. ++ UniquePtr other = make_self_signed(shape.is_ca, shape.usages); ++ ASSERT_TRUE(other); ++ ASSERT_TRUE(ASN1_INTEGER_set_uint64(X509_get_serialNumber(other.get()), 43)); ++ ASSERT_TRUE(X509_sign(other.get(), key.get(), EVP_sha256())); ++ EXPECT_EQ(X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT, ++ Verify(cert.get(), {other.get()}, {}, {}, 0, set_server_purpose)); ++ } ++ ++ // A trusted root whose keyUsage lacks keyCertSign may not issue anything: ++ // the chain builds, then the issuer edge fails with the root as the current ++ // certificate. ++ UniquePtr root = MakeTestCert("Root", "Root", key.get(), /*is_ca=*/true); ++ ASSERT_TRUE(root); ++ ASSERT_TRUE(AddKeyUsage(root.get(), kNoCertSign)); ++ ASSERT_TRUE(X509_sign(root.get(), key.get(), EVP_sha256())); ++ UniquePtr leaf = MakeTestCert("Root", "Leaf", key.get(), /*is_ca=*/false); ++ ASSERT_TRUE(leaf); ++ ASSERT_TRUE(X509_sign(leaf.get(), key.get(), EVP_sha256())); ++ int depth = -1; ++ auto record_depth = [&](X509_STORE_CTX *ctx) { ++ X509_STORE_CTX_set_verify_cb(ctx, [](int ok, X509_STORE_CTX *inner) -> int { ++ if (!ok) { ++ *static_cast(X509_STORE_CTX_get_app_data(inner)) = ++ X509_STORE_CTX_get_error_depth(inner); ++ } ++ return ok; ++ }); ++ X509_STORE_CTX_set_app_data(ctx, &depth); ++ }; ++ EXPECT_EQ(X509_V_ERR_KEYUSAGE_NO_CERTSIGN, ++ Verify(leaf.get(), {root.get()}, {}, {}, 0, record_depth)); ++ EXPECT_EQ(1, depth); ++ ++ // An untrusted intermediate without keyCertSign below a good root: the ++ // chain builds through it and its CA check fails first. ++ UniquePtr real_root = ++ MakeTestCert("Root", "Root", key.get(), /*is_ca=*/true); ++ ASSERT_TRUE(real_root); ++ ASSERT_TRUE(AddKeyUsage(real_root.get(), kCertSign)); ++ ASSERT_TRUE(X509_sign(real_root.get(), key.get(), EVP_sha256())); ++ UniquePtr intermediate = ++ MakeTestCert("Root", "Intermediate", key.get(), /*is_ca=*/true); ++ ASSERT_TRUE(intermediate); ++ ASSERT_TRUE(AddKeyUsage(intermediate.get(), kNoCertSign)); ++ ASSERT_TRUE(X509_sign(intermediate.get(), key.get(), EVP_sha256())); ++ UniquePtr leaf2 = ++ MakeTestCert("Intermediate", "Leaf", key.get(), /*is_ca=*/false); ++ ASSERT_TRUE(leaf2); ++ ASSERT_TRUE(X509_sign(leaf2.get(), key.get(), EVP_sha256())); ++ EXPECT_EQ(X509_V_ERR_INVALID_CA, ++ Verify(leaf2.get(), {real_root.get()}, {intermediate.get()}, {})); ++ ++ // `X509_check_issued` keeps the key usage check for its callers. ++ UniquePtr no_certsign = make_self_signed(false, kNoCertSign); ++ ASSERT_TRUE(no_certsign); ++ EXPECT_EQ(X509_V_ERR_KEYUSAGE_NO_CERTSIGN, ++ X509_check_issued(no_certsign.get(), no_certsign.get())); ++} ++ + TEST(X509Test, LazyCertSet) { + UniquePtr cross_signing_root(CertFromPEM(kCrossSigningRootPEM)); + UniquePtr root(CertFromPEM(kRootCAPEM)); +--- a/crypto/x509/x509_vfy.cc ++++ b/crypto/x509/x509_vfy.cc +@@ -435,7 +435,7 @@ static int untrusted_chain_error(const X509_STORE_CTX *ctx, int err) { + int bssl::x509_check_issued_with_callback(X509_STORE_CTX *ctx, const X509 *x, + const X509 *issuer) { + int ret; +- ret = X509_check_issued(issuer, x); ++ ret = x509_likely_issued(issuer, x); + if (ret == X509_V_OK) { + return 1; + } +@@ -1331,6 +1331,24 @@ static int internal_verify(X509_STORE_CTX *ctx) { + while (n >= 0) { + ctx->error_depth = n; + ++ // Candidate issuers are matched without looking at key usage (see ++ // `x509_likely_issued`), so RFC 5280, section 6.1.4, step (n) is applied ++ // here, on each issuer edge: an issuer whose keyUsage lacks keyCertSign ++ // may not have signed `xs`. A self-signed certificate is its own issuer ++ // and not an edge, so it is exempt, like an OpenSSL 3 anchor. ++ if (xs != xi) { ++ int err = x509_signing_allowed(xi); ++ if (err != X509_V_OK) { ++ ctx->error = err; ++ ctx->error_depth = n + 1; ++ ctx->current_cert = xi; ++ if (!call_verify_cb(0, ctx)) { ++ return 0; ++ } ++ ctx->error_depth = n; ++ } ++ } ++ + // Skip signature check for self signed certificates unless + // explicitly asked for. It doesn't add any security and just wastes + // time. +--- a/include/openssl/x509.h ++++ b/include/openssl/x509.h +@@ -215,9 +215,9 @@ OPENSSL_EXPORT void X509_get0_uids(const X509 *x509, + // should not be accepted. + #define EXFLAG_CRITICAL 0x200 + // EXFLAG_SS indicates the certificate is likely self-signed. That is, if it is +-// self-issued, its authority key identifier (if any) matches itself, and its +-// key usage extension (if any) allows certificate signatures. The signature +-// itself is not checked in computing this bit. ++// self-issued and its authority key identifier (if any) matches itself. The key ++// usage extension and the signature itself are not checked in computing this ++// bit. + #define EXFLAG_SS 0x2000 + + // X509_get_extension_flags decodes a set of extensions from `x509` and returns diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 9db3b212578d..d8ca4f3ef68d 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -24,7 +24,7 @@ import { quote } from "../shell.ts"; import type { Dependency, DirectBuild } from "../source.ts"; import { LIBC_ALLOCATION_SYMBOLS, depSourceDir } from "../source.ts"; -const BORINGSSL_COMMIT = "41bf9b59c2ebf277a7aa427e1ecad5cc80dd4d4f"; +export const BORINGSSL_COMMIT = "41bf9b59c2ebf277a7aa427e1ecad5cc80dd4d4f"; export const boringssl: Dependency = { name: "boringssl", @@ -36,6 +36,10 @@ export const boringssl: Dependency = { commit: BORINGSSL_COMMIT, }), + // Carried until the same change lands in oven-sh/boringssl and the pin + // moves past it. workarounds.ts trips at the next pin bump. + patches: ["patches/boringssl/self-signed-anchor-without-keycertsign.patch"], + build: cfg => { // win-x64 uses NASM-syntax .asm; everything else (including win-aarch64) // uses gas .S that clang assembles. diff --git a/scripts/build/workarounds.ts b/scripts/build/workarounds.ts index 97314c535842..1240d811745f 100644 --- a/scripts/build/workarounds.ts +++ b/scripts/build/workarounds.ts @@ -32,6 +32,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { Config } from "./config.ts"; +import { BORINGSSL_COMMIT } from "./deps/boringssl.ts"; import { BuildError } from "./error.ts"; import { satisfiesRange, toolchainOverride } from "./tools.ts"; @@ -65,6 +66,22 @@ export interface Workaround { } export const workarounds: Workaround[] = [ + { + id: "boringssl-self-signed-anchor-without-keycertsign", + issue: "https://github.com/oven-sh/bun/issues/44365", + description: + "BoringSSL's path builder requires keyCertSign on a candidate issuer, so a pinned self-signed " + + "certificate without it never anchors its own chain (OpenSSL 3 does not require it)", + applies: () => true, + // The patch is written against the pinned commit. The next bump must carry + // it into oven-sh/boringssl first, or re-validate it against the new tree. + expectedToBeFixed: () => BORINGSSL_COMMIT !== "41bf9b59c2ebf277a7aa427e1ecad5cc80dd4d4f", + cleanup: + "Land patches/boringssl/self-signed-anchor-without-keycertsign.patch in oven-sh/boringssl (it applies " + + "on top of 41bf9b59c2 and carries its own x509_test.cc cases), then remove the patch file, its entry " + + "in deps/boringssl.ts, and this check. If the change is not in the new pin, revalidate the patch and " + + "update this check's commit.", + }, { id: "darwin-cross-stack-size", issue: diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 2130a73e31d0..595c19c051e8 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -269,6 +269,8 @@ pub(super) mod ffi { ) -> c_int; // Returns X509_V_OK (0) when `issuer` could have issued `subject`. pub(crate) fn X509_check_issued(issuer: *mut X509, subject: *mut X509) -> c_int; + // Returns 0 when the two certificates have the same DER encoding. + pub(crate) fn X509_cmp(a: *const X509, b: *const X509) -> c_int; } } use crate::node::StringOrBuffer; @@ -536,11 +538,8 @@ pub(super) fn get_peer_certificate( // X509_STORE_CTX_get1_issuer to surface the root that completed // verification even though the peer never sent it. let mut last_is_self_issued = false; - // SAFETY: the store ctx is created, initialized against the live SSL_CTX's - // store, used only within this scope and freed before returning; every - // issuer returned by get1_issuer is a +1 reference collected in `extras` - // and released after its fields have been copied into JS values and the - // terminal self-issued check has run. + // SAFETY: the store ctx lives in this scope only. Each +1 issuer from + // get1_issuer is freed here: at once on a repeat, else from `extras`. unsafe { let mut store = ffi::SSL_CTX_get_cert_store(boringssl::SSL_CTX::opaque_ref( ffi::SSL_get_SSL_CTX(boringssl::SSL::opaque_ref(ssl_ptr)), @@ -578,6 +577,11 @@ pub(super) fn get_peer_certificate( { break; } + // A self-signed anchor without keyCertSign comes back again. Stop, like Node. + if !extras.is_empty() && ffi::X509_cmp(issuer, last_cert) == 0 { + boringssl::X509_free(issuer); + break; + } match X509::to_js(boringssl::X509::opaque_mut(issuer), global) { Ok(obj) => { prev_obj.put(global, b"issuerCertificate", obj); diff --git a/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts b/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts index ad4425d1fd95..76f195d14332 100644 --- a/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts +++ b/test/js/bun/test/parallel/test-http-should-not-accept-untrusted-certificates.ts @@ -31,6 +31,8 @@ try { await res.text(); expect.unreachable(); } catch (err) { - expect(err.code).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - expect(err.message).toBe("unable to verify the first certificate"); + // The fixture is an expired self-signed certificate. Node reports the last + // error of the walk, the validity check, and so does Bun. + expect(err.code).toBe("CERT_HAS_EXPIRED"); + expect(err.message).toBe("certificate has expired"); } diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 90ca9bcac27f..cfa6babadf03 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -672,8 +672,9 @@ it("process.versions", async () => { const expectedVersions = {}; for (const [key, file] of Object.entries(deps)) { const src = await Bun.file(join(depsDir, `${file}.ts`)).text(); - // No $ anchor: some pins carry a trailing comment (zlib.ts: `"; // 2.3.3`) - const match = src.match(/^const [A-Z_]+_COMMIT = "([0-9a-f]{40})";/m); + // No $ anchor: some pins carry a trailing comment (zlib.ts: `"; // 2.3.3`). + // A pin that workarounds.ts reads is exported (boringssl.ts). + const match = src.match(/^(?:export )?const [A-Z_]+_COMMIT = "([0-9a-f]{40})";/m); expect(match, `failed to extract commit from ${file}.ts`).not.toBeNull(); expectedVersions[key] = match[1]; } diff --git a/test/js/node/tls/fixtures/agent-no-keycertsign-ca-cert.pem b/test/js/node/tls/fixtures/agent-no-keycertsign-ca-cert.pem new file mode 100644 index 000000000000..c0229a58c854 --- /dev/null +++ b/test/js/node/tls/fixtures/agent-no-keycertsign-ca-cert.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBzDCCAXKgAwIBAgIUA/qX5vQQdG1Qgv0lWMbp4XYCOvUwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWQ0Egd2l0aG91dCBrZXlDZXJ0U2lnbjAgFw0yNjEwMDIwMjEw +MDZaGA8yMTI2MDkwODAyMTAwNlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYH +KoZIzj0CAQYIKoZIzj0DAQcDQgAEdXYjenbbokZcURm52+c7iDlkOpXsLRmor0I0 +ofjsCpvPXbGkswjcgo1G4FzzQZF9CbgVvGfnTMos/4ZbBPmYmaOBkjCBjzAaBgNV +HREEEzARgglsb2NhbGhvc3SHBH8AAAEwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8E +BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFPNnSLdn+b0qW3Le ++51vA96c6kMMMB8GA1UdIwQYMBaAFIlSFyqlu30VH16eclkgHIX/KDVpMAoGCCqG +SM49BAMCA0gAMEUCIQCWE3jfzGwDzAKWx0Sw6fFzjK0/nFQbXbjLCouLyH2XfAIg +Orn5aKWpNJpybjwDyq7BYvGsNK10KsWT9UsJlE8wNLI= +-----END CERTIFICATE----- diff --git a/test/js/node/tls/fixtures/agent-no-keycertsign-ca-key.pem b/test/js/node/tls/fixtures/agent-no-keycertsign-ca-key.pem new file mode 100644 index 000000000000..05a7e5c72c31 --- /dev/null +++ b/test/js/node/tls/fixtures/agent-no-keycertsign-ca-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgHwYP3HDZPICW+oXF +6SSTatXPRv0YdnE8SPsrsbczvKyhRANCAAR1diN6dtuiRlxRGbnb5zuIOWQ6lewt +GaivQjSh+OwKm89dsaSzCNyCjUbgXPNBkX0JuBW8Z+dMyiz/hlsE+ZiZ +-----END PRIVATE KEY----- diff --git a/test/js/node/tls/fixtures/ca-no-keycertsign-cert.pem b/test/js/node/tls/fixtures/ca-no-keycertsign-cert.pem new file mode 100644 index 000000000000..5d3c40d62733 --- /dev/null +++ b/test/js/node/tls/fixtures/ca-no-keycertsign-cert.pem @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBqTCCAU+gAwIBAgIUOg3ddkx7fKcimyZ/JBNtVCrsehgwCgYIKoZIzj0EAwIw +ITEfMB0GA1UEAwwWQ0Egd2l0aG91dCBrZXlDZXJ0U2lnbjAgFw0yNjEwMDIwMjEw +MDZaGA8yMTI2MDkwODAyMTAwNlowITEfMB0GA1UEAwwWQ0Egd2l0aG91dCBrZXlD +ZXJ0U2lnbjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABBtvlhOTargLTjcBY01I +r13f/U534aXYi2qNeNPlQy0m/YoJh2M/FBJxcuwXjtnCw3u0ExEetqM89mr6/Qq3 +au+jYzBhMB8GA1UdIwQYMBaAFIlSFyqlu30VH16eclkgHIX/KDVpMA8GA1UdEwEB +/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGCMB0GA1UdDgQWBBSJUhcqpbt9FR9ennJZ +IByF/yg1aTAKBggqhkjOPQQDAgNIADBFAiBEhbp60e3sncAgeZ+p4huJwYqamTm4 +fIBO8g6G4AH1BAIhANjtcqMQll934T3NRQvk2ZI+WrD2LXCvEsHxB0/czt7f +-----END CERTIFICATE----- diff --git a/test/js/node/tls/node-tls-cert.test.ts b/test/js/node/tls/node-tls-cert.test.ts index 3911a9ec0617..643e04d0cea9 100644 --- a/test/js/node/tls/node-tls-cert.test.ts +++ b/test/js/node/tls/node-tls-cert.test.ts @@ -541,8 +541,10 @@ it("tls.connect should not accept untrusted certificates", async () => { }); const err = await promise; - expect(err.code).toBe("UNABLE_TO_VERIFY_LEAF_SIGNATURE"); - expect(err.message).toBe("unable to verify the first certificate"); + // The fixture is an expired self-signed certificate. Node reports the + // last error of the walk, the validity check, and so does Bun. + expect(err.code).toBe("CERT_HAS_EXPIRED"); + expect(err.message).toBe("certificate has expired"); } finally { //@ts-ignore socket?.end(); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 2dbb669d7f79..6e7fd0d08f4e 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1,7 +1,7 @@ import { heapStats } from "bun:jsc"; import { describe, expect, it } from "bun:test"; import { once } from "events"; -import { writeFileSync } from "fs"; +import { readFileSync, writeFileSync } from "fs"; import { bunEnv, bunExe, bunRun, tls as COMMON_CERT_, isASAN, nodeExe, tempDir } from "harness"; import https from "https"; import net from "net"; @@ -2332,6 +2332,49 @@ describe("rejectUnauthorized only treats a literal `false` as opting out", () => }); }); +describe("keyCertSign on the trust anchor", () => { + const fixtures = join(import.meta.dir, "fixtures"); + const nodeKeys = join(import.meta.dir, "..", "test", "fixtures", "keys", "selfsigned-no-keycertsign"); + const read = (path: string) => readFileSync(path, "utf8"); + + async function attempt(serverOptions: tls.TlsOptions, ca?: string) { + const server = tls.createServer(serverOptions, s => s.end()); + let client: TLSSocket | undefined; + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const { promise, resolve } = Promise.withResolvers(); + client = tlsConnect( + { port: (server.address() as AddressInfo).port, host: "127.0.0.1", servername: "localhost", ca }, + () => resolve(`authorized ${client!.authorized}`), + ); + client.on("error", (error: Error & { code?: string }) => resolve(`error ${error.code}`)); + return await promise; + } finally { + client?.destroy(); + server.close(); + } + } + + it("a pinned self-signed leaf without keyCertSign is its own anchor, like Node", async () => { + const cert = read(join(nodeKeys, "cert.pem")); + const key = read(join(nodeKeys, "key.pem")); + expect(await attempt({ cert, key }, cert)).toBe("authorized true"); + // Unpinned, it is an untrusted self-signed certificate (not expired, so + // the trust decision is what the code reports). + expect(await attempt({ cert, key })).toBe("error DEPTH_ZERO_SELF_SIGNED_CERT"); + }); + + it("a trusted issuer without keyCertSign may not sign a leaf", async () => { + const cert = read(join(fixtures, "agent-no-keycertsign-ca-cert.pem")); + const key = read(join(fixtures, "agent-no-keycertsign-ca-key.pem")); + const ca = read(join(fixtures, "ca-no-keycertsign-cert.pem")); + // X509_V_ERR_KEYUSAGE_NO_CERTSIGN has no name in the node:tls error table, + // so Node reports it as UNSPECIFIED too. + expect(await attempt({ cert, key }, ca)).toBe("error UNSPECIFIED"); + }); +}); + it("a server using `crl` must not poison the process-wide default CA store", async () => { // An mTLS server with `crl` and no `ca` shares the process-wide default // root store; the CRL flags must land on a private copy or every later diff --git a/test/js/node/tls/test-node-extra-ca-certs.test.ts b/test/js/node/tls/test-node-extra-ca-certs.test.ts index b703e7f9a089..aec5be5ab272 100644 --- a/test/js/node/tls/test-node-extra-ca-certs.test.ts +++ b/test/js/node/tls/test-node-extra-ca-certs.test.ts @@ -170,3 +170,55 @@ test("explicit ca option replaces the default trust store instead of appending t expect(stderr).toBe(""); expect(exitCode).toBe(0); }); + +test("NODE_EXTRA_CA_CERTS trusts a self-signed certificate whose keyUsage lacks keyCertSign", async () => { + // The shape of a `dotnet dev-certs` certificate: CA:FALSE, keyUsage = + // digitalSignature + keyEncipherment, EKU = serverAuth. Node trusts the + // pinned copy as the anchor of its own one-certificate chain. + const keys = join(import.meta.dir, "..", "test", "fixtures", "keys", "selfsigned-no-keycertsign"); + + await using dir = tempDir("extra-ca-no-keycertsign", { + "main.js": ` + import tls from "node:tls"; + import fs from "node:fs"; + import { once } from "node:events"; + const cert = fs.readFileSync(process.env.NODE_EXTRA_CA_CERTS, "utf8"); + const key = fs.readFileSync(process.env.TLS_KEY, "utf8"); + + async function main() { + const server = Bun.serve({ port: 0, tls: { cert, key }, fetch: () => new Response("ok") }); + const res = await fetch("https://localhost:" + server.port + "/"); + console.log("fetch", res.status, await res.text()); + + const socket = tls.connect({ host: "127.0.0.1", port: server.port, servername: "localhost" }); + await once(socket, "secureConnect"); + console.log("tls authorized", socket.authorized, socket.authorizationError); + // The walk ends at the pinned copy, as in Node: the leaf cannot + // sign, so it is not its own issuer, and the store has nothing above. + const peer = socket.getPeerCertificate(true); + const anchor = peer.issuerCertificate; + console.log("issuerCertificate is the pinned copy", anchor.fingerprint256 === peer.fingerprint256); + console.log("chain ends there", anchor.issuerCertificate === undefined); + socket.end(); + await once(socket, "close"); + server.stop(true); + } + main(); + `, + }); + + await using proc = spawn({ + cmd: [bunExe(), "main.js"], + env: { ...bunEnv, NODE_EXTRA_CA_CERTS: join(keys, "cert.pem"), TLS_KEY: join(keys, "key.pem") }, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe( + "fetch 200 ok\ntls authorized true null\nissuerCertificate is the pinned copy true\nchain ends there true\n", + ); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); diff --git a/test/js/web/fetch/fetch.tls.test.ts b/test/js/web/fetch/fetch.tls.test.ts index 3cf57b86d0cc..94d0dfe9537d 100644 --- a/test/js/web/fetch/fetch.tls.test.ts +++ b/test/js/web/fetch/fetch.tls.test.ts @@ -24,6 +24,21 @@ const CERT_LOCALHOST_ONLY = { key: readFileSync(join(import.meta.dir, "../../../regression/issue/27890-localhost-only.key"), "utf8"), }; +const NODE_KEYS = join(import.meta.dir, "../../node/test/fixtures/keys"); +const TLS_FIXTURES = join(import.meta.dir, "../../node/tls/fixtures"); +// Self-signed leaf whose keyUsage is digitalSignature + keyEncipherment, with +// no keyCertSign: the shape `dotnet dev-certs` and `openssl req -x509` produce. +const CERT_NO_KEYCERTSIGN = { + cert: readFileSync(join(NODE_KEYS, "selfsigned-no-keycertsign/cert.pem"), "utf8"), + key: readFileSync(join(NODE_KEYS, "selfsigned-no-keycertsign/key.pem"), "utf8"), +}; +// Leaf issued by a CA:TRUE certificate whose keyUsage lacks keyCertSign. +const CERT_ISSUED_BY_CA_WITHOUT_KEYCERTSIGN = { + cert: readFileSync(join(TLS_FIXTURES, "agent-no-keycertsign-ca-cert.pem"), "utf8"), + key: readFileSync(join(TLS_FIXTURES, "agent-no-keycertsign-ca-key.pem"), "utf8"), + ca: readFileSync(join(TLS_FIXTURES, "ca-no-keycertsign-cert.pem"), "utf8"), +}; + // Note: Do not use bun.sh as the example domain // Cloudflare sometimes blocks automated requests to it. // so it will cause flaky tests. @@ -658,6 +673,46 @@ describe.concurrent("fetch-tls", () => { }); }); + it("trusts a pinned self-signed certificate whose keyUsage lacks keyCertSign", async () => { + // Node (OpenSSL 3) anchors the chain on an exact copy of the leaf in the + // trust store regardless of keyUsage. keyCertSign only matters for a + // certificate that signs another one. + await createServer(CERT_NO_KEYCERTSIGN, async port => { + const urls = [`https://localhost:${port}`, `https://127.0.0.1:${port}`]; + const results = await Promise.all( + urls.map(url => + fetch(url, { keepalive: false, tls: { ca: CERT_NO_KEYCERTSIGN.cert } }).then(res => res.text()), + ), + ); + expect(results).toEqual(["Hello World", "Hello World"]); + + // Unpinned, it is a self-signed certificate with no anchor, like Node. + try { + await fetch(urls[0], { keepalive: false }).then(res => res.blob()); + expect.unreachable(); + } catch (e: any) { + expect(e.code).toBe("DEPTH_ZERO_SELF_SIGNED_CERT"); + } + }); + }); + + it("rejects a leaf whose trusted issuer lacks keyCertSign", async () => { + // The issuer is matched by name, so the chain builds, and the issuer edge + // then fails on key usage. RFC 5280, section 6.1.4, step (n). + const { cert, key } = CERT_ISSUED_BY_CA_WITHOUT_KEYCERTSIGN; + await createServer({ cert, key }, async port => { + try { + await fetch(`https://localhost:${port}`, { + keepalive: false, + tls: { ca: CERT_ISSUED_BY_CA_WITHOUT_KEYCERTSIGN.ca }, + }).then(res => res.blob()); + expect.unreachable(); + } catch (e: any) { + expect(e.code).toBe("KEYUSAGE_NO_CERTSIGN"); + } + }); + }); + it("fetch with invalid tls should throw", async () => { await createServer(CERT_EXPIRED, async port => { await Promise.all(