diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 1523165a4c3d..fa1e74c51cec 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -163,6 +163,11 @@ static int us_ssl_bio_type = 0; * entry — see us_ssl_ctx_set_sni_policy. Absent on node:tls SecureContexts, * whose policy is server-level. */ static int us_ctx_sni_policy_ex_idx = -1; +/* (SSL_CTX) us_ssl_ctx_identities_t: the identities of a context built from several key and certificate pairs. + * BoringSSL has no getter for what SSL_CTX_add1_credential stored, and us_ssl_use_ctx_identity copies them to an SSL. */ +static int us_ctx_identities_ex_idx = -1; +/* (SSL_CREDENTIAL) Non-NULL on those identities: every connection of the context signs with the same object. */ +static int us_credential_shared_ex_idx = -1; /* Defined in Rust (src/uws_sys/SocketKind.rs) so the ordinal tracks the enum. */ extern const unsigned char BUN_SOCKET_KIND_BUN_SOCKET_TLS; extern const unsigned char BUN_SOCKET_KIND_UWS_HTTP_TLS; @@ -213,6 +218,8 @@ struct us_ssl_rare_t { void (*session_sink_free)(void *sink); uint64_t reneg_window_start_ms; uint32_t reneg_count; + /* A setKeyCert() put an identity on this server connection: see us_ssl_apply_selected_ctx. */ + uint8_t has_key_cert; }; static void us_ctx_ex_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, @@ -220,6 +227,21 @@ static void us_ctx_ex_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, (void)parent; (void)ptr; (void)ad; (void)index; (void)argl; (void)argp; atomic_fetch_sub(&ssl_ctx_live, 1); } + +/* In OpenSSL's order of preference: BoringSSL serves the first usable credential. */ +enum { US_SSL_IDENTITY_ECDSA, US_SSL_IDENTITY_EDDSA, US_SSL_IDENTITY_RSA, US_SSL_IDENTITY_TYPES }; +struct us_ssl_ctx_identities_t { + SSL_CREDENTIAL *identity[US_SSL_IDENTITY_TYPES]; +}; + +static void us_ctx_identities_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + struct us_ssl_ctx_identities_t *identities = ptr; + if (!identities) return; + for (int type = 0; type < US_SSL_IDENTITY_TYPES; type++) SSL_CREDENTIAL_free(identities->identity[type]); + us_free(identities); +} static struct us_server_names_t *us_server_names_ref(struct us_listen_socket_t *ls); static void us_server_names_unref(struct us_server_names_t *names); static void us_ssl_rare_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, @@ -373,6 +395,8 @@ static void us_ex_idx_init(void) { us_ctx_cache_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, bun_ssl_ctx_cache_on_free); us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_sni_policy_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ctx_identities_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, us_ctx_identities_free); + us_credential_shared_ex_idx = SSL_CREDENTIAL_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_rare_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_rare_free); us_ssl_wrapper_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_bio_type = BIO_get_new_index() | BIO_TYPE_SOURCE_SINK; @@ -1054,20 +1078,26 @@ static int us_ssl_ctx_use_certificate_chain(SSL_CTX *ctx, const char *content) { return ret; } -/* BoringSSL's ssl_auto_chain_if_needed, which at handshake time completes the legacy slot but no credential. */ -static int us_ssl_ctx_auto_chain(SSL_CTX *ctx) { - if (sk_CRYPTO_BUFFER_num(SSL_CTX_get0_chain(ctx)) != 1) return 1; +/* The certificates above `leaf`, as far as `store` has them. The caller frees the result. */ +static STACK_OF(X509) *us_ssl_issuers(X509_STORE *store, X509 *leaf) { X509_STORE_CTX *walk = X509_STORE_CTX_new(); - if (walk == NULL || !X509_STORE_CTX_init(walk, SSL_CTX_get_cert_store(ctx), SSL_CTX_get0_certificate(ctx), NULL)) { + if (walk == NULL || !X509_STORE_CTX_init(walk, store, leaf, NULL)) { X509_STORE_CTX_free(walk); - return 0; + return NULL; } X509_verify_cert(walk); ERR_clear_error(); STACK_OF(X509) *chain = X509_STORE_CTX_get1_chain(walk); X509_STORE_CTX_free(walk); + if (chain != NULL) X509_free(sk_X509_shift(chain)); + return chain; +} + +/* BoringSSL's ssl_auto_chain_if_needed, which at handshake time completes the legacy slot but no credential. */ +static int us_ssl_ctx_auto_chain(SSL_CTX *ctx) { + if (sk_CRYPTO_BUFFER_num(SSL_CTX_get0_chain(ctx)) != 1) return 1; + STACK_OF(X509) *chain = us_ssl_issuers(SSL_CTX_get_cert_store(ctx), SSL_CTX_get0_certificate(ctx)); if (chain == NULL) return 0; - X509_free(sk_X509_shift(chain)); int ok = SSL_CTX_set1_chain(ctx, chain); sk_X509_pop_free(chain, X509_free); return ok; @@ -1080,8 +1110,10 @@ static SSL_CREDENTIAL *us_ssl_ctx_copy_legacy_identity(SSL_CTX *ctx) { if (certs == NULL) return NULL; for (size_t i = 0; i < count; i++) certs[i] = sk_CRYPTO_BUFFER_value(chain, i); SSL_CREDENTIAL *identity = SSL_CREDENTIAL_new_x509(); + us_ex_idx_ensure(); if (identity != NULL && (!SSL_CREDENTIAL_set1_cert_chain(identity, certs, count) || - !SSL_CREDENTIAL_set1_private_key(identity, SSL_CTX_get0_privatekey(ctx)))) { + !SSL_CREDENTIAL_set1_private_key(identity, SSL_CTX_get0_privatekey(ctx)) || + !SSL_CREDENTIAL_set_ex_data(identity, us_credential_shared_ex_idx, identity))) { SSL_CREDENTIAL_free(identity); identity = NULL; } @@ -1092,8 +1124,8 @@ static SSL_CREDENTIAL *us_ssl_ctx_copy_legacy_identity(SSL_CTX *ctx) { /* One identity per key type, as in OpenSSL: the last certificate of a type wins, with whichever key fits it. */ static int us_ssl_ctx_use_identities(SSL_CTX *ctx, const char *const *cert, unsigned int cert_count, const char *const *key, unsigned int key_count) { - /* In OpenSSL's order of preference: BoringSSL serves the first usable credential. */ - enum { TYPE_ECDSA, TYPE_EDDSA, TYPE_RSA, KEY_TYPES }; + enum { TYPE_ECDSA = US_SSL_IDENTITY_ECDSA, TYPE_EDDSA = US_SSL_IDENTITY_EDDSA, TYPE_RSA = US_SSL_IDENTITY_RSA, + KEY_TYPES = US_SSL_IDENTITY_TYPES }; const char *leaf[KEY_TYPES] = {NULL}; SSL_CREDENTIAL *identity[KEY_TYPES] = {NULL}; int ok = 1; @@ -1125,9 +1157,20 @@ static int us_ssl_ctx_use_identities(SSL_CTX *ctx, const char *const *cert, unsi ok = 0; } } + struct us_ssl_ctx_identities_t *identities = ok ? us_calloc(1, sizeof(*identities)) : NULL; + ok = ok && identities != NULL; for (int type = 0; type < KEY_TYPES; type++) { if (ok && identity[type] != NULL) ok = SSL_CTX_add1_credential(ctx, identity[type]); - SSL_CREDENTIAL_free(identity[type]); + if (identities != NULL) { + identities->identity[type] = identity[type]; + } else { + SSL_CREDENTIAL_free(identity[type]); + } + } + us_ex_idx_ensure(); + if (identities != NULL && (!ok || !SSL_CTX_set_ex_data(ctx, us_ctx_identities_ex_idx, identities))) { + us_ctx_identities_free(NULL, identities, NULL, 0, 0, NULL); + ok = 0; } return ok; } @@ -3196,6 +3239,21 @@ void us_ssl_ctx_set_sni_policy(SSL_CTX *ctx, int request_cert, int reject_unauth * (RFC 6066 section 3; openssl/ssl.h: "partition session caches between SNI * hosts") and must not be relaxed for parity with another runtime. */ static void us_ssl_apply_selected_ctx(SSL *ssl, SSL_CTX *ctx) { + struct us_ssl_rare_t *rare = us_ssl_rare(ssl); + if (rare != NULL && rare->has_key_cert) { + /* This context replaces what setKeyCert() put on the connection (us_ssl_use_ctx_identity). The switch below does + * that for the identity, the verify store and the session id context, but it does nothing when the connection is on + * `ctx` already: a step to another context first makes BoringSSL copy those again. The client-CA names are kept + * apart from them. */ + rare->has_key_cert = 0; + SSL_set0_client_CAs(ssl, NULL); + if (SSL_get_SSL_CTX(ssl) == ctx) { + SSL_CTX *elsewhere = SSL_CTX_new(TLS_method()); + if (elsewhere == NULL) Bun__outOfMemory(); + SSL_set_SSL_CTX(ssl, elsewhere); + SSL_CTX_free(elsewhere); + } + } SSL_set_SSL_CTX(ssl, ctx); if (us_ctx_sni_policy_ex_idx < 0) return; uintptr_t policy = (uintptr_t)SSL_CTX_get_ex_data(ctx, us_ctx_sni_policy_ex_idx); @@ -3205,6 +3263,120 @@ static void us_ssl_apply_selected_ctx(SSL *ssl, SSL_CTX *ctx) { SSL_set_verify(ssl, mode, us_verify_callback); } +/* After the handshake picked its credential (a call from an ALPNCallback, or later), BoringSSL signs with that object + * and with the signature algorithm it chose for it. A new identity changes the connection's own credential in place, so + * the new key has to fit that choice: the same type, the same curve, an RSA key that is not shorter. */ +static int us_ssl_key_fits_handshake(SSL *ssl, EVP_PKEY *key) { + const SSL_CREDENTIAL *picked = SSL_get0_selected_credential(ssl); + if (picked == NULL) return 1; + /* One of several identities is an object that every connection of its context signs with. It stays as it is. */ + if (key == NULL || SSL_CREDENTIAL_get_ex_data(picked, us_credential_shared_ex_idx) != NULL) return 0; + EVP_PKEY *own = SSL_get_privatekey(ssl); + const SSL_CIPHER *cipher = SSL_get_pending_cipher(ssl); + if (own == NULL || EVP_PKEY_id(own) != EVP_PKEY_id(key)) return 0; + /* With an RSA key exchange the client encrypts to the certificate that it got, once the server's flight is out. */ + if (cipher != NULL && SSL_CIPHER_get_kx_nid(cipher) == NID_kx_rsa && SSL_get_write_sequence(ssl) != 0) return 0; + if (EVP_PKEY_id(key) == EVP_PKEY_EC) return EVP_PKEY_get_ec_curve_nid(own) == EVP_PKEY_get_ec_curve_nid(key); + if (EVP_PKEY_id(key) == EVP_PKEY_RSA) return EVP_PKEY_bits(key) >= EVP_PKEY_bits(own); + return 1; +} + +/* A certificate that came alone gets its issuers from the store of its context (a pfx, `ca`). When that has none, + * BoringSSL looks in the store of the connection's context as it picks the certificate. After the pick it does not + * look again, so this does. */ +static int us_ssl_complete_chain(SSL *ssl, SSL_CTX *ctx) { + X509 *leaf = SSL_CTX_get0_certificate(ctx); + STACK_OF(X509) *issuers = us_ssl_issuers(SSL_CTX_get_cert_store(ctx), leaf); + if (issuers != NULL && sk_X509_num(issuers) == 0 && SSL_get0_selected_credential(ssl) != NULL) { + sk_X509_pop_free(issuers, X509_free); + issuers = us_ssl_issuers(SSL_CTX_get_cert_store(SSL_get_SSL_CTX(ssl)), leaf); + } + if (issuers == NULL) Bun__outOfMemory(); + int ok = sk_X509_num(issuers) == 0 || SSL_set1_chain(ssl, issuers); + sk_X509_pop_free(issuers, X509_free); + return ok; +} + +/* Replaces the certificates and the keys of `ssl` with those of `ctx`. `key` is that of the context's own certificate, + * which of several certificates can be one that no key fits: then only `identities` are complete. */ +static int us_ssl_use_ctx_certificates(SSL *ssl, SSL_CTX *ctx, EVP_PKEY *key, + const struct us_ssl_ctx_identities_t *identities) { + int ok = 1; + /* The credentials that the connection copied from its own context would be tried before the new ones. */ + SSL_certs_clear(ssl); + if (key != NULL) { + const STACK_OF(CRYPTO_BUFFER) *chain = SSL_CTX_get0_chain(ctx); + size_t count = sk_CRYPTO_BUFFER_num(chain); + CRYPTO_BUFFER **certs = us_calloc(count, sizeof(*certs)); + if (certs == NULL) Bun__outOfMemory(); + for (size_t i = 0; i < count; i++) certs[i] = sk_CRYPTO_BUFFER_value(chain, i); + ok = SSL_set_chain_and_key(ssl, certs, count, key, NULL) && (count != 1 || us_ssl_complete_chain(ssl, ctx)); + us_free(certs); + } + for (int type = 0; ok && identities != NULL && type < US_SSL_IDENTITY_TYPES; type++) { + if (identities->identity[type] != NULL) ok = SSL_add1_credential(ssl, identities->identity[type]); + } + return ok; +} + +/* One buffer for each client-CA name that setKeyCert() puts on connections, however many of them hold it. */ +static CRYPTO_BUFFER_POOL *us_ssl_client_ca_pool(void) { + static _Atomic(CRYPTO_BUFFER_POOL *) shared = NULL; + CRYPTO_BUFFER_POOL *pool = atomic_load(&shared); + if (pool != NULL) return pool; + CRYPTO_BUFFER_POOL *created = CRYPTO_BUFFER_POOL_new(); + if (created == NULL) Bun__outOfMemory(); + if (atomic_compare_exchange_strong(&shared, &pool, created)) return created; + CRYPTO_BUFFER_POOL_free(created); + return pool; +} + +/* The names that a server sends with its request for a client certificate: those of `ctx`, also when it has none. */ +static void us_ssl_use_ctx_client_ca_names(SSL *ssl, SSL_CTX *ctx) { + STACK_OF(X509_NAME) *names = SSL_CTX_get_client_CA_list(ctx); + /* An empty list and not NULL, which stands for the names of the connection's SSL_CTX. */ + STACK_OF(CRYPTO_BUFFER) *buffers = sk_CRYPTO_BUFFER_new_null(); + if (buffers == NULL) Bun__outOfMemory(); + for (size_t i = 0; i < sk_X509_NAME_num(names); i++) { + uint8_t *der = NULL; + int len = i2d_X509_NAME(sk_X509_NAME_value(names, i), &der); + CRYPTO_BUFFER *name = len < 0 ? NULL : CRYPTO_BUFFER_new(der, (size_t)len, us_ssl_client_ca_pool()); + OPENSSL_free(der); + if (name == NULL || !sk_CRYPTO_BUFFER_push(buffers, name)) Bun__outOfMemory(); + } + SSL_set0_client_CAs(ssl, buffers); +} + +/* tlsSocket.setKeyCert(): puts the identity and the trust of `ctx` on a live connection, as Node's TLSWrap::SetKeyCert + * does. The connection stays on the SSL_CTX it was created on or that SNI selected. BoringSSL reads the early callback + * and the ALPN callback from that context (us_select_cert_cb, select_alpn_callback), so an SNICallback that has not + * answered keeps the handshake parked and ALPN is still negotiated. `sid` is the session id context of `ctx`, which a + * server takes with the identity (see us_ssl_apply_selected_ctx). Returns 0 for a server when `ctx` has no certificate + * with its key: the connection is unchanged then. Also returns 0 when BoringSSL fails a step, which takes an allocation + * failure and can leave the connection without its certificates. A client takes what `ctx` has. */ +int us_ssl_use_ctx_identity(SSL *ssl, SSL_CTX *ctx, const uint8_t *sid, size_t sid_len) { + us_ex_idx_ensure(); + const struct us_ssl_ctx_identities_t *identities = SSL_CTX_get_ex_data(ctx, us_ctx_identities_ex_idx); + EVP_PKEY *key = sk_CRYPTO_BUFFER_value(SSL_CTX_get0_chain(ctx), 0) != NULL ? SSL_CTX_get0_privatekey(ctx) : NULL; + int has_identity = key != NULL; + for (int type = 0; identities != NULL && type < US_SSL_IDENTITY_TYPES; type++) { + has_identity |= identities->identity[type] != NULL; + } + /* Node refuses a context without an identity on a server, and ignores every call on a client. Here a client keeps + * its own certificates then, and takes the CA. */ + if (!has_identity && SSL_is_server(ssl)) return 0; + if (has_identity && us_ssl_key_fits_handshake(ssl, key) && !us_ssl_use_ctx_certificates(ssl, ctx, key, identities)) { + return 0; + } + /* Node's SetCACerts: the store that the peer's certificate is verified against, and for a server the names above. */ + if (!SSL_set1_verify_cert_store(ssl, SSL_CTX_get_cert_store(ctx))) return 0; + if (!SSL_is_server(ssl)) return 1; + us_ssl_use_ctx_client_ca_names(ssl, ctx); + if (!SSL_set_session_id_context(ssl, sid, sid_len)) return 0; + us_ssl_rare_ensure(ssl)->has_key_cert = 1; + return 1; +} + /* Whether the SNI-selected context of this connection demands closing on a * client-certificate verification error (requestCert && rejectUnauthorized * of the per-serverName entry). */ @@ -3281,7 +3453,7 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h * through to the static SNI tree below, exactly like a synchronous * resolver returning null - the resume must not skip the tree fallback * the sync path gets. */ - if (names) { + if (names && !rare->has_key_cert) { /* Read the servername from the raw ClientHello, same as the first-call * path below: that is the read the early-callback contract guarantees * (SSL_get_servername happens to be populated by the resume re-drive @@ -3352,8 +3524,10 @@ static enum ssl_select_cert_result_t us_select_cert_cb(const SSL_CLIENT_HELLO *h } /* No dynamic selection: fall back to the static SNI tree (the - * addContext() entries). An adopted socket has no tree. */ - struct sni_node_t *node = us_server_names_find(names, hostname); + * addContext() entries). An adopted socket has no tree. A connection that + * has the identity of a setKeyCert() keeps it: Node does not look at the + * addContext() entries of a server with an SNICallback. */ + struct sni_node_t *node = rare && rare->has_key_cert ? NULL : us_server_names_find(names, hostname); if (node) { us_ssl_apply_selected_ctx(ssl, node->ctx); } diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index 53e5276f9d13..8b4c82eae5fa 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -453,6 +453,10 @@ int us_ssl_ctx_reject_unauthorized(struct ssl_ctx_st *ctx); /* Socket-level SNI resolver, for a server-side socket adopted into TLS with no * listen socket behind it: us_dispatch_socket_server_name then resolves. */ void us_socket_on_server_name(us_socket_r s); +/* setKeyCert(): serves `ssl` with the identity of `ctx` and verifies its peer against the CA of `ctx`, without a + * change of the connection's SSL_CTX. `sid` is the session id context of `ctx`. Returns 0 for a server when `ctx` has + * no certificate with its key, and when BoringSSL fails to allocate. */ +int us_ssl_use_ctx_identity(struct ssl_st *ssl, struct ssl_ctx_st *ctx, const unsigned char *sid, size_t sid_len); /* ── Connect ────────────────────────────────────────────────────────────── * Returns either us_socket_t* (fast path, *is_connecting=1) or diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 77e19e8f0263..cbf74a57f828 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -200,30 +200,13 @@ pub(super) mod ffi { pub(crate) safe fn SSL_set_ex_data(ssl: &SSL, idx: c_int, data: *mut c_void) -> c_int; // Returns the borrowed parent CTX (always non-null for a live `SSL*`). pub(crate) safe fn SSL_get_SSL_CTX(ssl: &SSL) -> *mut SSL_CTX; - // Swaps the cert/key/chain (and session-related state) this connection - // serves to those of `ctx`; takes its own reference to `ctx`. - pub(crate) fn SSL_set_SSL_CTX(ssl: *mut SSL, ctx: *mut SSL_CTX) -> *mut SSL_CTX; - // Apply `ctx`'s leaf certificate / private key / extra chain directly - // to the connection - SSL_set_SSL_CTX alone does not retarget the - // certificate once ClientHello processing has reached ALPN selection. - pub(crate) fn SSL_CTX_get0_certificate(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; - pub(crate) fn SSL_CTX_get0_privatekey(ctx: *const SSL_CTX) -> *mut core::ffi::c_void; - pub(crate) fn SSL_use_certificate( + // openssl.c: returns 0 for a server when `ctx` has no certificate with its key. + pub(crate) fn us_ssl_use_ctx_identity( ssl: *mut SSL, - x509: *mut core::ffi::c_void, - ) -> core::ffi::c_int; - pub(crate) fn SSL_use_PrivateKey( - ssl: *mut SSL, - pkey: *mut core::ffi::c_void, - ) -> core::ffi::c_int; - pub(crate) fn SSL_CTX_get0_chain_certs( - ctx: *const SSL_CTX, - out_chain: *mut *mut core::ffi::c_void, - ) -> core::ffi::c_int; - pub(crate) fn SSL_set1_chain( - ssl: *mut SSL, - chain: *mut core::ffi::c_void, - ) -> core::ffi::c_int; + ctx: *mut SSL_CTX, + sid: *const u8, + sid_len: usize, + ) -> c_int; // Stores `cb`/`arg` opaquely on the CTX (BoringSSL never derefs `arg` // outside the callback). Opaque-ZST `&SSL_CTX` + by-value fn-ptr + // opaque `*mut c_void` ⇒ no caller-side precondition. @@ -867,9 +850,7 @@ pub(super) fn get_tls_peer_finished_message( Ok(buffer) } -/// `tlsSocket.setKeyCert(secureContext)` - serve this connection's identity -/// from the given context: SSL_set_SSL_CTX swaps the cert/key/chain used for -/// the rest of the handshake (Node calls it from ALPNCallback / SNICallback). +/// `tlsSocket.setKeyCert(secureContext)`: see `us_ssl_use_ctx_identity` in usockets' openssl.c. pub(crate) fn set_key_cert( this: &This, global: &JSGlobalObject, @@ -888,33 +869,22 @@ pub(crate) fn set_key_cert( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::UNDEFINED); }; - // SAFETY: `sc` is a live SecureContext; SSL_set_SSL_CTX takes its own reference. - unsafe { - let ctx = &(*sc).ctx; - ffi::SSL_set_SSL_CTX(ssl_ptr.cast(), ctx.as_ptr().cast()); - // A client keeps no session id context (see `us_internal_ssl_attach`). - if ffi::SSL_is_server(boringssl::SSL::opaque_ref(ssl_ptr)) == 0 { - boringssl::SSL_set_session_id_context(ssl_ptr, core::ptr::null(), 0); - } - // SSL_set_SSL_CTX stops retargeting the certificate once ClientHello - // processing has reached ALPN selection, and Node supports calling - // setKeyCert from ALPNCallback - apply the identity directly. - let leaf = ffi::SSL_CTX_get0_certificate(ctx.as_ptr().cast()); - let pkey = ffi::SSL_CTX_get0_privatekey(ctx.as_ptr().cast()); - if !leaf.is_null() && !pkey.is_null() { - let ok_cert = ffi::SSL_use_certificate(ssl_ptr.cast(), leaf); - let ok_key = ffi::SSL_use_PrivateKey(ssl_ptr.cast(), pkey); - let mut ok_chain = 1; - let mut chain: *mut core::ffi::c_void = core::ptr::null_mut(); - if ffi::SSL_CTX_get0_chain_certs(ctx.as_ptr().cast(), &raw mut chain) == 1 - && !chain.is_null() - { - ok_chain = ffi::SSL_set1_chain(ssl_ptr.cast(), chain); - } - if ok_cert != 1 || ok_key != 1 || ok_chain != 1 { - return Err(global.throw(format_args!("setKeyCert failed to apply the context"))); - } - } + // SAFETY: `sc` is a live SecureContext and `ssl_ptr` the live SSL of this + // socket; the call takes its own references and copies the digest. + let applied = unsafe { + let sc = &*sc; + // The context's session id context (`create_ssl_context_with_digest`). + let digest = sc.digest.get(); + ffi::us_ssl_use_ctx_identity( + ssl_ptr.cast(), + sc.ctx.as_ptr().cast(), + digest.as_ptr(), + digest.len(), + ) + }; + if applied == 0 { + // Node's message: https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1674-L1677 + return Err(global.throw(format_args!("SetKeyCert"))); } Ok(JSValue::UNDEFINED) } diff --git a/test/js/node/test/parallel/test-tls-server-setkeycert.js b/test/js/node/test/parallel/test-tls-server-setkeycert.js new file mode 100644 index 000000000000..6d9f619a1be8 --- /dev/null +++ b/test/js/node/test/parallel/test-tls-server-setkeycert.js @@ -0,0 +1,56 @@ +'use strict'; +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { X509Certificate } = require('crypto'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const altKeyCert = { + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + minVersion: 'TLSv1.2', +}; + +const altKeyCertVals = [ + altKeyCert, + tls.createSecureContext(altKeyCert), +]; + +(function next() { + if (!altKeyCertVals.length) + return; + const altKeyCertVal = altKeyCertVals.shift(); + const options = { + key: fixtures.readKey('agent1-key.pem'), + cert: fixtures.readKey('agent1-cert.pem'), + minVersion: 'TLSv1.3', + ALPNCallback: common.mustCall(function({ servername, protocols }) { + this.setKeyCert(altKeyCertVal); + assert.deepStrictEqual(protocols, ['acme-tls/1']); + return protocols[0]; + }), + }; + + tls.createServer(options, (s) => s.end()).listen(0, common.mustCall(function() { + this.on('connection', common.mustCall((socket) => this.close())); + + tls.connect({ + port: this.address().port, + rejectUnauthorized: false, + ALPNProtocols: ['acme-tls/1'], + }, common.mustCall(function() { + assert.strictEqual(this.getProtocol(), 'TLSv1.3'); + const altCert = new X509Certificate(altKeyCert.cert); + assert.strictEqual( + this.getPeerX509Certificate().raw.equals(altCert.raw), + true + ); + this.end(); + next(); + })); + })); +})(); diff --git a/test/js/node/tls/node-tls-context.test.ts b/test/js/node/tls/node-tls-context.test.ts index ac31c5eb5377..4a8272bb97c9 100644 --- a/test/js/node/tls/node-tls-context.test.ts +++ b/test/js/node/tls/node-tls-context.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "bun:test"; +import { sslCtxLiveCount } from "bun:internal-for-testing"; import { bunEnv, bunExe, tempDir } from "harness"; import { X509Certificate } from "node:crypto"; import { once } from "node:events"; @@ -703,8 +704,8 @@ describe.each(["TLSv1.3", "TLSv1.2"] as const)("session resumption across SNI co } }); - // `setKeyCert()` moves the connection to another context. On a client that - // must not take the context's session id context either. + // `setKeyCert()` gives the connection the identity and the CA of another + // context. A client must not take the session id context of that one. it("lets a client that calls setKeyCert() still resume", async () => { const other = tls.createSecureContext({ key: agent3Key, cert: agent3Cert }); const server = await listen(); @@ -863,6 +864,150 @@ describe.each(["TLSv1.2", "TLSv1.3"] as const)("ALPN after SNI selected a Secure }); }); +// The doors that only Bun has. node-tls-setkeycert.test.ts has the rows that Node.js has too. +describe("setKeyCert() on a server-side socket", () => { + const own = { key: agent2Key, cert: agent2Cert }; + const other = tls.createSecureContext({ key: agent1Key, cert: agent1Cert }); + + // One handshake that offers h2: the certificate and the protocol that the client got. `until` keeps the client open. + async function dial(port: number, options: tls.ConnectionOptions = {}, until?: Promise) { + const client = tls.connect({ + port, + host: "127.0.0.1", + servername: "a.test", + ALPNProtocols: ["h2"], + rejectUnauthorized: false, + ...options, + }); + try { + await once(client, "secureConnect"); + await until; + return { cn: client.getPeerCertificate().subject.CN, alpn: client.alpnProtocol }; + } finally { + client.destroy(); + } + } + + // Bun emits 'connection' with the TLSSocket, before the ClientHello. + it.each([ + ["selects nothing", null, { cn: "agent1", alpn: "h2", calls: 1 }], + ["refuses", new Error("denied"), { refused: true, calls: 1 }], + ] as const)("in a 'connection' listener: the SNICallback still runs and %s", async (_, answer, expected) => { + let calls = 0; + const SNICallback = (_: string, callback: (err: Error | null) => void) => { + calls++; + callback(answer); + }; + const server = tls.createServer({ ...own, SNICallback, ALPNProtocols: ["h2"] }, socket => + socket.on("error", () => {}), + ); + server.on("tlsClientError", () => {}); + server.on("connection", socket => (socket as tls.TLSSocket).setKeyCert(other)); + await once(server.listen(0, "127.0.0.1"), "listening"); + try { + const got = await dial((server.address() as AddressInfo).port).catch(() => ({ refused: true })); + expect({ ...got, calls }).toEqual(expected); + } finally { + server.close(); + } + }); + + // The connection holds what it took from the context, and not the context. No other context has these options, so + // the call builds one that nothing else holds. + it("serves from a context that was collected before the SNICallback answered", async () => { + const verdict = Promise.withResolvers(); + let collected = false; + const server = tls.createServer( + { + ...own, + requestCert: true, + rejectUnauthorized: false, + ALPNProtocols: ["h2"], + SNICallback(this: tls.TLSSocket, _: string, callback: (err: Error | null) => void) { + Bun.gc(true); + const before = sslCtxLiveCount(); + this.setKeyCert({ key: agent1Key, cert: agent1Cert, ca: [ca2], sessionTimeout: 4321 }); + let collections = 0; + const poll = () => { + Bun.gc(true); + collected = sslCtxLiveCount() <= before; + if (collected || ++collections === 50) callback(null); + else setImmediate(poll); + }; + setImmediate(poll); + }, + }, + socket => { + socket.on("error", () => {}); + verdict.resolve(socket.authorized ? "authorized" : String(socket.authorizationError)); + }, + ); + server.on("tlsClientError", () => {}); + // `this` in the SNICallback is the TLSSocket for a connection that the server adopts. + const front = net.createServer(raw => server.emit("connection", raw)); + await once(front.listen(0, "127.0.0.1"), "listening"); + try { + const port = (front.address() as AddressInfo).port; + const got = await dial(port, { key: agent3Key, cert: agent3Cert }, verdict.promise); + expect({ ...got, verdict: await verdict.promise, collected }).toEqual({ + cn: "agent1", + alpn: "h2", + verdict: "authorized", + collected: true, + }); + } finally { + front.close(); + } + }); + + // With a handshake handler, Bun.listen() calls the open handler before the handshake. The native method takes the + // native context. + it("Bun.listen(): in the open handler", async () => { + let thrown: unknown; + using listener = Bun.listen({ + hostname: "127.0.0.1", + port: 0, + tls: own, + socket: { + open(socket) { + try { + //@ts-expect-error + socket.setKeyCert(tls.createSecureContext().context); + } catch (err) { + thrown = (err as Error).message; + } + //@ts-expect-error + socket.setKeyCert(other.context); + }, + handshake() {}, + data() {}, + error() {}, + }, + }); + const { cn } = await dial(listener.port); + expect({ cn, thrown }).toEqual({ cn: "agent1", thrown: "SetKeyCert" }); + }); + + // The order that stopped a debug build in BoringSSL, in a process of its own: a listening server with an + // ALPNCallback gave the context to addContext() too, and its SNICallback answers from a microtask. + it("an answer from a microtask, with an ALPNCallback and the context in addContext()", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), join(import.meta.dir, "node-tls-setkeycert-fixture.mjs")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout.split("\n")).toEqual([ + JSON.stringify({ version: "TLSv1.2", cn: "agent1", alpn: "h2", calls: 1 }), + JSON.stringify({ version: "TLSv1.3", cn: "agent1", alpn: "h2", calls: 1 }), + "", + ]); + expect({ exitCode, signal: proc.signalCode }).toEqual({ exitCode: 0, signal: null }); + }); +}); + describe("Bun.serve SNI", () => { function doClientRequest(options: any) { return new Promise((resolve, reject) => { diff --git a/test/js/node/tls/node-tls-setkeycert-fixture.mjs b/test/js/node/tls/node-tls-setkeycert-fixture.mjs new file mode 100644 index 000000000000..b8e486ed09b1 --- /dev/null +++ b/test/js/node/tls/node-tls-setkeycert-fixture.mjs @@ -0,0 +1,55 @@ +// One handshake for each TLS version with a listening server whose SNICallback calls setKeyCert() and answers from a +// microtask. The server also has an ALPNCallback, and gave the same context to addContext(). Prints what each client +// got and how many times the ALPNCallback ran. Runs on Bun and on Node.js. +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import tls from "node:tls"; + +const keys = join(import.meta.dirname, "..", "test", "fixtures", "keys"); +const identity = name => ({ + key: readFileSync(join(keys, `${name}-key.pem`)), + cert: readFileSync(join(keys, `${name}-cert.pem`)), +}); +const context = tls.createSecureContext(identity("agent1")); + +for (const version of ["TLSv1.2", "TLSv1.3"]) { + let calls = 0; + let accepted; + const server = tls.createServer( + { + ...identity("agent2"), + ALPNCallback({ protocols }) { + calls++; + return protocols[0]; + }, + async SNICallback(_, answer) { + // Node.js calls it with the socket as `this`. Bun emits 'connection' with that socket. + (this instanceof tls.TLSSocket ? this : accepted).setKeyCert(context); + await Promise.resolve(); + answer(null); + }, + }, + socket => socket.on("error", () => {}).end(), + ); + server.addContext("other.test", context); + server.on("connection", socket => (accepted = socket)); + server.on("tlsClientError", () => {}); + await once(server.listen(0, "127.0.0.1"), "listening"); + const client = tls.connect({ + port: server.address().port, + host: "127.0.0.1", + servername: "a.test", + ALPNProtocols: ["h2"], + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + const result = await once(client, "secureConnect").then( + () => ({ cn: client.getPeerCertificate().subject.CN, alpn: client.alpnProtocol }), + err => ({ error: err.code ?? err.message }), + ); + console.log(JSON.stringify({ version, ...result, calls })); + client.destroy(); + server.close(); +} diff --git a/test/js/node/tls/node-tls-setkeycert.test.ts b/test/js/node/tls/node-tls-setkeycert.test.ts new file mode 100644 index 000000000000..e6db97c0c3fb --- /dev/null +++ b/test/js/node/tls/node-tls-setkeycert.test.ts @@ -0,0 +1,984 @@ +/** + * tlsSocket.setKeyCert() on a server-side socket. Node.js puts the certificate, the key and the CA of the context on the + * connection, and the handshake goes on as before: the answer of the SNICallback is awaited and ALPN is negotiated. + * https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1656-L1681 + * + * Works with both: + * bun bd test test/js/node/tls/node-tls-setkeycert.test.ts + * node --test test/js/node/tls/node-tls-setkeycert.test.ts + */ +import assert from "node:assert"; +import { once } from "node:events"; +import { readFileSync } from "node:fs"; +import net from "node:net"; +import { join } from "node:path"; +import { Duplex } from "node:stream"; +import { describe, test } from "node:test"; +import tls from "node:tls"; + +const onBun = typeof Bun !== "undefined"; +const keys = join(import.meta.dirname, "..", "test", "fixtures", "keys"); +const fixtures = join(import.meta.dirname, "fixtures"); +const pem = (name: string) => readFileSync(join(keys, name)); +const identity = (name: string) => ({ key: pem(`${name}-key.pem`), cert: pem(`${name}-cert.pem`) }); +// The server's own certificate is agent2, and most tests give the connection agent1. ca1 issued agent1 and ca2 issued +// agent3. The keys of ec10 (CN agent10.example.com) and of ec (CN agent2) are on the curve P-256, the others are RSA. +const own = identity("agent2"); +const other = tls.createSecureContext(identity("agent1")); +const third = tls.createSecureContext(identity("agent3")); + +type Answer = (err: Error | null, context?: tls.SecureContext) => void; +type SNICallback = (this: tls.TLSSocket, servername: string, answer: Answer) => void; +type Version = "TLSv1.2" | "TLSv1.3"; +type Context = tls.SecureContext | tls.SecureContextOptions; +const versions = ["TLSv1.2", "TLSv1.3"] as const; + +async function listen(server: net.Server) { + await once(server.listen(0, "127.0.0.1"), "listening"); + return { port: (server.address() as net.AddressInfo).port, host: "127.0.0.1" }; +} + +// A Duplex in front of the socket: the server-side TLS then runs over a stream, and not over the socket itself. +function overDuplex(raw: net.Socket) { + const duplex = new Duplex({ + read() {}, + write(chunk, encoding, callback) { + raw.write(chunk, encoding, callback); + }, + final(callback) { + raw.end(); + callback(); + }, + destroy(err, callback) { + raw.destroy(); + callback(err); + }, + }); + raw.on("data", chunk => duplex.push(chunk)); + raw.on("end", () => duplex.push(null)); + raw.on("error", () => {}); + raw.on("close", () => duplex.destroy()); + return duplex; +} + +// A door makes the server-side TLSSocket of each connection. +type Dial = { port: number; host: string }; +type Door = { dial: Dial; close(): void }; +type Report = { + onSecure?: (socket: tls.TLSSocket) => void; + onError?: (err: Error) => void; + onServer?: (server: tls.Server) => void; +}; +type OpenDoor = (options: tls.TlsOptions, report?: Report) => Promise; + +function emitter(over: (raw: net.Socket) => Duplex): OpenDoor { + return async (options, report = {}) => { + const server = tls.createServer(options, socket => { + socket.on("error", () => {}); + report.onSecure?.(socket); + }); + server.on("tlsClientError", err => report.onError?.(err)); + const front = net.createServer(raw => server.emit("connection", over(raw))); + return { dial: await listen(front), close: () => front.close() }; + }; +} + +function wrapper(over: (raw: net.Socket) => Duplex): OpenDoor { + return async (options, report = {}) => { + const front = net.createServer(raw => { + const socket = new tls.TLSSocket(over(raw), { ...options, isServer: true }); + socket.on("error", err => report.onError?.(err)); + socket.on("secure", () => report.onSecure?.(socket)); + }); + return { dial: await listen(front), close: () => front.close() }; + }; +} + +// On these, `this` in the SNICallback is the TLSSocket of the connection. +const doors: Record = { + // Node.js calls the SNICallback of a listening server with the socket as `this`. Where Bun does not do that yet, the + // socket comes from 'connection', which Bun emits with the TLSSocket. + "server.listen()": async (options, report = {}) => { + let accepted: tls.TLSSocket | undefined; + const callback = options.SNICallback as SNICallback | undefined; + const SNICallback = function (this: unknown, servername: string, answer: Answer) { + callback!.call(this instanceof tls.TLSSocket ? this : accepted!, servername, answer); + }; + const server = tls.createServer({ ...options, ...(callback && { SNICallback }) }, socket => { + socket.on("error", () => {}); + report.onSecure?.(socket); + }); + server.on("connection", socket => (accepted = socket as tls.TLSSocket)); + server.on("tlsClientError", err => report.onError?.(err)); + report.onServer?.(server); + return { dial: await listen(server), close: () => server.close() }; + }, + "server.emit('connection', netSocket)": emitter(raw => raw), + "new TLSSocket(netSocket, { isServer: true })": wrapper(raw => raw), +}; +// Bun does not call an SNICallback for a connection over a stream. +const streamDoors: Record = { + "server.emit('connection', duplex)": emitter(overDuplex), + "new TLSSocket(duplex, { isServer: true })": wrapper(overDuplex), +}; +const emit = doors["server.emit('connection', netSocket)"]; +const wrap = doors["new TLSSocket(netSocket, { isServer: true })"]; + +async function serve(open: OpenDoor, options: tls.TlsOptions, run: (dial: Dial) => T | Promise, report?: Report) { + const opened = await open(options, report); + try { + return await run(opened.dial); + } finally { + opened.close(); + } +} + +// When the SNICallback answers, after its setKeyCert() call. +const answers: Record void> = { + "in the call": (answer, context) => answer(null, context), + "from a microtask": (answer, context) => queueMicrotask(() => answer(null, context)), + "on a later turn": (answer, context) => setImmediate(answer, null, context), +}; + +const pick = (context: Context | (() => Context)) => (typeof context === "function" ? context() : context); +// An SNICallback that calls setKeyCert() and then selects nothing. +const applies = (context: Context | (() => Context)): SNICallback => + function (_, answer) { + this.setKeyCert(pick(context)); + answer(null); + }; +// An ALPNCallback that calls setKeyCert() and then selects the first protocol. +const selects = (context: Context | (() => Context)) => + function (this: tls.TLSSocket, { protocols }: { protocols: string[] }) { + this.setKeyCert(pick(context)); + return protocols[0]; + }; + +const served = (client: tls.TLSSocket) => ({ cn: client.getPeerCertificate().subject.CN, alpn: client.alpnProtocol }); +const refused = () => "refused" as const; + +// One handshake that offers h2. `read` takes the result from the client, by default its certificate and protocol. +// `session` is the one that the server issues, which can come before or after 'secureConnect'. +async function handshake>( + dial: Dial, + version: Version, + options: tls.ConnectionOptions = {}, + read: (client: tls.TLSSocket, session: Promise) => T | Promise = served as (client: tls.TLSSocket) => T, +) { + const client = tls.connect({ + ...dial, + servername: "a.test", + ALPNProtocols: ["h2"], + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + ...options, + }); + const session = once(client, "session").then(([issued]) => issued as Buffer); + session.catch(() => {}); + try { + await once(client, "secureConnect"); + return await read(client, session); + } finally { + client.destroy(); + } +} + +// A client that takes a certificate of one key type only. +const only = (type: "RSA" | "ECDSA", version: Version): tls.ConnectionOptions => + version === "TLSv1.2" + ? { ciphers: `ECDHE-${type}-AES128-GCM-SHA256` } + : { sigalgs: type === "RSA" ? "rsa_pss_rsae_sha256" : "ecdsa_secp256r1_sha256" }; + +// How many certificates came: the peer's own and the issuers with it. +function chainLength(socket: tls.TLSSocket) { + const sent = new Set(); + let cert = socket.getPeerCertificate(true); + while (cert?.raw && !sent.has(cert.fingerprint256)) { + sent.add(cert.fingerprint256); + cert = cert.issuerCertificate; + } + return sent.size; +} + +// The first flight of a client: one ClientHello record, for a peer that sends nothing more. +async function clientHello(version: Version) { + const { promise, resolve } = Promise.withResolvers(); + const sink = net.createServer(raw => { + let seen = Buffer.alloc(0); + raw.on("error", () => {}); + raw.on("data", (chunk: Buffer) => { + seen = Buffer.concat([seen, chunk]); + if (seen.length >= 5 && seen.length >= 5 + seen.readUInt16BE(3)) + resolve(seen.subarray(0, 5 + seen.readUInt16BE(3))); + }); + }); + const client = tls.connect({ + ...(await listen(sink)), + servername: "a.test", + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + client.on("error", () => {}); + try { + return await promise; + } finally { + client.destroy(); + sink.close(); + } +} + +// The handshake messages in a flight of TLS 1.2, which sends them in the clear: the body of each, by its type. +function handshakeMessages(flight: Buffer) { + const records: Buffer[] = []; + for (let at = 0; at + 5 <= flight.length; at += 5 + flight.readUInt16BE(at + 3)) { + if (flight[at] === 22) records.push(flight.subarray(at + 5, at + 5 + flight.readUInt16BE(at + 3))); + } + const stream = Buffer.concat(records); + const messages = new Map(); + for (let at = 0; at + 4 <= stream.length; at += 4 + stream.readUIntBE(at + 1, 3)) { + messages.set(stream[at], stream.subarray(at + 4, at + 4 + stream.readUIntBE(at + 1, 3))); + } + return messages; +} +const CERTIFICATE_REQUEST = 13; +const SERVER_HELLO_DONE = 14; + +// Which of the three CAs a TLS 1.2 server names in its request for a client certificate. The peer reads up to the +// ServerHelloDone and stops. +async function namedCAs(dial: Dial) { + const hello = await clientHello("TLSv1.2"); + const peer = net.connect(dial); + const flight = Promise.withResolvers>(); + let received = Buffer.alloc(0); + peer.on("error", flight.reject); + peer.on("close", () => flight.reject(new Error("the server closed before its ServerHelloDone"))); + peer.on("data", (chunk: Buffer) => { + received = Buffer.concat([received, chunk]); + const messages = handshakeMessages(received); + if (messages.has(SERVER_HELLO_DONE)) flight.resolve(messages); + }); + try { + peer.write(hello); + const names = (await flight.promise).get(CERTIFICATE_REQUEST)?.toString("latin1") ?? ""; + return { ca1: names.includes("ca1"), ca2: names.includes("ca2"), ca3: names.includes("ca3") }; + } finally { + peer.destroy(); + } +} + +for (const [door, open] of Object.entries({ ...doors, ...streamDoors })) { + for (const version of versions) { + const suite = onBun && door in streamDoors ? describe.skip : describe; + suite(`setKeyCert() in an SNICallback, ${door}, ${version}`, () => { + for (const [when, give] of Object.entries(answers)) { + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(other); + give(answer); + }; + + test(`ALPNProtocols still apply when the answer comes ${when}`, async () => { + const got = await serve(open, { ...own, SNICallback, ALPNProtocols: ["h2"] }, dial => + handshake(dial, version), + ); + assert.deepStrictEqual(got, { cn: "agent1", alpn: "h2" }); + }); + + test(`the ALPNCallback still runs once when the answer comes ${when}`, async () => { + let calls = 0; + const ALPNCallback = ({ protocols }: { protocols: string[] }) => { + calls++; + return protocols[0]; + }; + const got = await serve(open, { ...own, SNICallback, ALPNCallback }, dial => handshake(dial, version)); + assert.deepStrictEqual({ ...got, calls }, { cn: "agent1", alpn: "h2", calls: 1 }); + }); + } + + test("the call can come on a later turn, before the answer", async () => { + const SNICallback: SNICallback = function (_, answer) { + setImmediate(() => { + this.setKeyCert(other); + answer(null); + }); + }; + const got = await serve(open, { ...own, SNICallback, ALPNProtocols: ["h2"] }, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: "agent1", alpn: "h2" }); + }); + + // The peer sends its ClientHello and nothing more, so every byte it gets was sent before the answer. + test("the server sends nothing before the answer, and a refusal drops the connection", async () => { + const refusal = new Error("denied"); + const reported = Promise.withResolvers(); + let secured = false; + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(other); + setImmediate(answer, refusal); + }; + const hello = await clientHello(version); + const got = await serve( + open, + { ...own, SNICallback }, + async dial => { + const peer = net.connect(dial); + let received = 0; + peer.on("error", () => {}); + peer.on("data", chunk => (received += chunk.length)); + try { + peer.write(hello); + const [err] = await Promise.all([reported.promise, once(peer, "close")]); + return { received, secured, isTheRefusal: err === refusal }; + } finally { + peer.destroy(); + } + }, + { onSecure: () => (secured = true), onError: reported.resolve }, + ); + assert.deepStrictEqual(got, { received: 0, secured: false, isTheRefusal: true }); + }); + + // The connection is on the server's own context here, and the answer can name that one or another. + for (const [when, give] of [Object.entries(answers)[0], Object.entries(answers)[2]]) { + test(`a context in the answer replaces the one of the call, when the answer comes ${when}`, async () => { + const secureContext = tls.createSecureContext(own); + const answered: Record = {}; + for (const [which, context] of Object.entries({ + "another context": third, + "the server's own": secureContext, + })) { + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(other); + give(answer, context); + }; + const options = { secureContext, SNICallback, ALPNProtocols: ["h2"] }; + answered[which] = await serve(open, options, dial => handshake(dial, version)); + } + assert.deepStrictEqual(answered, { + "another context": { cn: "agent3", alpn: "h2" }, + "the server's own": { cn: "agent2", alpn: "h2" }, + }); + }); + } + }); + + // The use that the Node.js documentation names for setKeyCert(). + test(`setKeyCert() in an ALPNCallback replaces the certificate, ${door}, ${version}`, async () => { + const got = await serve(open, { ...own, ALPNCallback: selects(other) }, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: "agent1", alpn: "h2" }); + }); + } +} + +for (const version of versions) { + describe(`what setKeyCert() puts on the connection, ${version}`, () => { + test("the last of two calls wins", async () => { + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(other); + this.setKeyCert(third); + answer(null); + }; + const got = await serve(emit, { ...own, SNICallback, ALPNProtocols: ["h2"] }, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: "agent3", alpn: "h2" }); + }); + + // Node.js does not look at the addContext() entries of a server that has an SNICallback. Bun looks when the + // SNICallback selects nothing, but not for a connection that has the certificate of a setKeyCert() call. + for (const [when, give] of [Object.entries(answers)[0], Object.entries(answers)[2]]) { + test(`an addContext() entry for the name does not replace it, when the answer comes ${when}`, async () => { + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(other); + give(answer); + }; + const got = await serve( + doors["server.listen()"], + { ...own, SNICallback, ALPNProtocols: ["h2"] }, + dial => handshake(dial, version), + { onServer: server => server.addContext("a.test", identity("agent3")) }, + ); + assert.deepStrictEqual(got, { cn: "agent1", alpn: "h2" }); + }); + } + + test("the options of a context do as a context", async () => { + const options = { ...own, SNICallback: applies(identity("agent1")), ALPNProtocols: ["h2"] }; + assert.deepStrictEqual(await serve(emit, options, dial => handshake(dial, version)), { + cn: "agent1", + alpn: "h2", + }); + }); + + test("a context without a certificate throws, and the connection keeps its own", async () => { + const thrown: unknown[] = []; + const SNICallback: SNICallback = function (_, answer) { + for (const context of [tls.createSecureContext(), tls.createSecureContext({ ca: pem("ca1-cert.pem") })]) { + try { + this.setKeyCert(context); + } catch (err) { + thrown.push(err); + } + } + answer(null); + }; + const got = await serve(emit, { ...own, SNICallback, ALPNProtocols: ["h2"] }, dial => handshake(dial, version)); + assert.deepStrictEqual( + { ...got, thrown: thrown.map(err => (err instanceof Error ? `${err.name}: ${err.message}` : err)) }, + { cn: "agent2", alpn: "h2", thrown: ["Error: SetKeyCert", "Error: SetKeyCert"] }, + ); + }); + + test("a context without a certificate in an ALPNCallback, not caught, refuses the handshake", async () => { + const options = { ...own, ALPNCallback: selects(() => tls.createSecureContext()) }; + assert.strictEqual(await serve(emit, options, dial => handshake(dial, version).catch(refused)), "refused"); + }); + + // Of two certificates only the RSA one has its key. + test("a context with a certificate that has no key serves the one that has its key", async () => { + const context = tls.createSecureContext({ + cert: [pem("agent1-cert.pem"), pem("ec10-cert.pem")], + key: [pem("agent1-key.pem")], + }); + const options = { ...identity("agent3"), SNICallback: applies(context), ALPNProtocols: ["h2"] }; + assert.deepStrictEqual(await serve(emit, options, dial => handshake(dial, version)), { + cn: "agent1", + alpn: "h2", + }); + }); + + test("a key on the same curve in an ALPNCallback replaces the certificate", async () => { + const context = tls.createSecureContext(identity("ec")); + const got = await serve(emit, { ...identity("ec10"), ALPNCallback: selects(context) }, dial => + handshake(dial, version, {}, client => ({ ...served(client), curve: client.getPeerCertificate().asn1Curve })), + ); + assert.deepStrictEqual(got, { cn: "agent2", alpn: "h2", curve: "prime256v1" }); + }); + + // The certificate follows the cipher suite (TLS 1.2) or the signature algorithm (TLS 1.3), and an ECDSA client + // keeps the ECDSA certificate. + test("a key of another type in an ALPNCallback leaves the certificate", async () => { + const options = { ...identity("ec10"), ALPNCallback: selects(other) }; + const got = await serve(emit, options, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: "agent10.example.com", alpn: "h2" }); + }); + + // The server trusts ca1 and the context trusts ca2. In the ALPNCallback the key is of another type, so the + // certificate stays, and the CA moves all the same. + for (const [where, callbacks] of [ + ["an SNICallback", (context: tls.SecureContext) => ({ ...own, SNICallback: applies(context) })], + ["an ALPNCallback", (context: tls.SecureContext) => ({ ...identity("ec10"), ALPNCallback: selects(context) })], + ] as const) { + const trustsCa2 = () => tls.createSecureContext({ ...identity("agent1"), ca: pem("ca2-cert.pem") }); + + test(`the CA of the context verifies the client certificate, from ${where}`, async () => { + let verdict = Promise.withResolvers(); + const options = { + ...callbacks(trustsCa2()), + ca: pem("ca1-cert.pem"), + requestCert: true, + rejectUnauthorized: false, + }; + const verdicts = await serve( + emit, + options, + async dial => { + const each: string[] = []; + for (const client of ["agent1", "agent3"]) { + each.push(await handshake(dial, version, identity(client), () => verdict.promise)); + verdict = Promise.withResolvers(); + } + return each; + }, + { onSecure: socket => verdict.resolve(socket.authorized ? "authorized" : String(socket.authorizationError)) }, + ); + assert.deepStrictEqual(verdicts, ["UNABLE_TO_VERIFY_LEAF_SIGNATURE", "authorized"]); + }); + + test(`a server that rejects takes only the clients of the context's CA, from ${where}`, async () => { + let outcome = Promise.withResolvers(); + const options = { ...callbacks(trustsCa2()), ca: pem("ca1-cert.pem"), requestCert: true }; + const outcomes = await serve( + emit, + options, + async dial => { + const each: string[] = []; + for (const name of ["agent1", "agent3"]) { + const client = tls.connect({ + ...dial, + ...identity(name), + servername: "a.test", + ALPNProtocols: ["h2"], + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + client.on("error", () => {}); + each.push(await outcome.promise); + client.destroy(); + outcome = Promise.withResolvers(); + } + return each; + }, + { onSecure: () => outcome.resolve("accepted"), onError: () => outcome.resolve("refused") }, + ); + assert.deepStrictEqual(outcomes, ["refused", "accepted"]); + }); + } + + // Not from the CA of the context: the server finds the issuer of agent1 in its own CA, and none for agent3. + test("getPeerCertificate(true) looks for the issuer in the CA of the server", async () => { + const trustsCa2 = tls.createSecureContext({ ...identity("agent1"), ca: pem("ca2-cert.pem") }); + let chain = Promise.withResolvers(); + const options = { + ...own, + ca: pem("ca1-cert.pem"), + requestCert: true, + rejectUnauthorized: false, + SNICallback: applies(trustsCa2), + }; + const chains = await serve( + emit, + options, + async dial => { + const each: number[] = []; + for (const client of ["agent1", "agent3"]) { + each.push(await handshake(dial, version, identity(client), () => chain.promise)); + chain = Promise.withResolvers(); + } + return each; + }, + { onSecure: socket => chain.resolve(chainLength(socket)) }, + ); + assert.deepStrictEqual(chains, [2, 1]); + }); + + // agent6.pfx holds the two certificates above agent6, and agent6-cert.pem holds the first of them. + test("the intermediate certificates of the context are sent", async () => { + const contexts = [ + tls.createSecureContext({ pfx: pem("agent6.pfx"), passphrase: "sample" }), + tls.createSecureContext(identity("agent6")), + ]; + let next = 0; + const sent = await serve(emit, { ...own, SNICallback: applies(() => contexts[next++]) }, async dial => [ + await handshake(dial, version, {}, chainLength), + await handshake(dial, version, {}, chainLength), + ]); + assert.deepStrictEqual(sent, [3, 2]); + }); + + // The context has agent6 alone. The two certificates above it are in the CA of the server. + test("a certificate from an ALPNCallback gets its issuers from the CA of the server", async () => { + const [certificate] = pem("agent6-cert.pem") + .toString() + .split(/(?<=-----END CERTIFICATE-----)/); + const alone = tls.createSecureContext({ key: pem("agent6-key.pem"), cert: certificate }); + const options = { ...own, ca: [pem("ca3-cert.pem"), pem("ca1-cert.pem")], ALPNCallback: selects(alone) }; + assert.strictEqual(await serve(emit, options, dial => handshake(dial, version, {}, chainLength)), 3); + }); + }); +} + +describe("what setKeyCert() puts on the connection, TLSv1.2 only", () => { + test("the cipher suites of the context do not apply", async () => { + const aes256 = tls.createSecureContext({ ...identity("agent1"), ciphers: "ECDHE-RSA-AES256-GCM-SHA384" }); + const options = { ...own, ciphers: "ECDHE-RSA-AES128-GCM-SHA256", SNICallback: applies(aes256) }; + const got = await serve(emit, options, dial => + handshake(dial, "TLSv1.2", {}, client => ({ + cn: client.getPeerCertificate().subject.CN, + cipher: client.getCipher().name, + })), + ); + assert.deepStrictEqual(got, { cn: "agent1", cipher: "ECDHE-RSA-AES128-GCM-SHA256" }); + }); + + // With this cipher suite the client encrypts to the key of the certificate that it got. + test("an RSA key exchange uses the key that an ALPNCallback set", async () => { + const options = { ...own, ciphers: "AES128-GCM-SHA256", ALPNCallback: selects(other) }; + const got = await serve(emit, options, dial => + handshake(dial, "TLSv1.2", { ciphers: "AES128-GCM-SHA256" }, client => ({ + ...served(client), + cipher: client.getCipher().name, + })), + ); + assert.deepStrictEqual(got, { cn: "agent1", alpn: "h2", cipher: "AES128-GCM-SHA256" }); + }); + + // The server's own context names ca1. + const requests = { ...own, ca: pem("ca1-cert.pem"), requestCert: true, rejectUnauthorized: false }; + const naming = (ca: string) => tls.createSecureContext({ ...own, ca: pem(`${ca}-cert.pem`) }); + + test("the request for a client certificate names the CA of the context", async () => { + const options = { ...requests, SNICallback: applies(naming("ca2")) }; + assert.deepStrictEqual(await serve(emit, options, namedCAs), { ca1: false, ca2: true, ca3: false }); + }); + + test("the request for a client certificate names no CA for a context without one", async () => { + const options = { ...requests, SNICallback: applies(tls.createSecureContext(own)) }; + assert.deepStrictEqual(await serve(emit, options, namedCAs), { ca1: false, ca2: false, ca3: false }); + }); + + for (const [when, give] of [Object.entries(answers)[0], Object.entries(answers)[2]]) { + test(`the request names the CA of a context in the answer, when the answer comes ${when}`, async () => { + const secureContext = tls.createSecureContext({ ...own, ca: pem("ca1-cert.pem") }); + const named: Record = {}; + for (const [which, context] of Object.entries({ + "another context": naming("ca3"), + "the server's own": secureContext, + })) { + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(naming("ca2")); + give(answer, context); + }; + const options = { secureContext, requestCert: true, rejectUnauthorized: false, SNICallback }; + named[which] = await serve(wrap, options, namedCAs); + } + assert.deepStrictEqual(named, { + "another context": { ca1: false, ca2: false, ca3: true }, + "the server's own": { ca1: true, ca2: false, ca3: false }, + }); + }); + } + + // Its CA comes back too: the server's own context trusts ca1, which issued agent1. + test("the server's own context in the answer verifies the client certificate again", async () => { + const secureContext = tls.createSecureContext({ ...own, ca: pem("ca1-cert.pem") }); + const trustsCa2 = tls.createSecureContext({ ...identity("agent1"), ca: pem("ca2-cert.pem") }); + const verdict = Promise.withResolvers(); + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(trustsCa2); + answer(null, secureContext); + }; + const got = await serve( + emit, + { secureContext, requestCert: true, rejectUnauthorized: false, SNICallback }, + dial => handshake(dial, "TLSv1.2", identity("agent1"), () => verdict.promise), + { onSecure: socket => verdict.resolve(socket.authorized ? "authorized" : String(socket.authorizationError)) }, + ); + assert.strictEqual(got, "authorized"); + }); +}); + +// Bun differs from Node.js in these. Each test states both results. +for (const version of versions) { + describe(`where setKeyCert() differs from Node.js, ${version}`, () => { + // Bun keeps the sessions of two contexts apart, as it does for the contexts that SNI selects. + test("a session is resumed only under the context that issued it", async () => { + const contexts: Record = { "a.test": other, "b.test": third }; + const SNICallback: SNICallback = function (servername, answer) { + this.setKeyCert(contexts[servername]); + answer(null); + }; + // BoringSSL holds the session tickets of TLS 1.3 back until the server writes. + const reused = await serve( + emit, + { ...own, SNICallback }, + async dial => { + const session = await handshake(dial, version, {}, (_, issued) => issued); + const resumes = (servername: string) => + handshake(dial, version, { servername, session }, client => client.isSessionReused()); + return { "a.test": await resumes("a.test"), "b.test": await resumes("b.test") }; + }, + { onSecure: socket => socket.end("x") }, + ); + assert.deepStrictEqual(reused, { "a.test": true, "b.test": !onBun }); + }); + + // OpenSSL picks the certificate after the ALPNCallback, and BoringSSL before it. Bun replaces the certificate when + // the new key fits what the handshake picked for the old one, and leaves it otherwise. + for (const [which, from, to, bun, node] of [ + [ + "on another curve", + identity("ec10"), + { key: "p521-key.pem", cert: "p521-cert.pem" }, + "agent10.example.com", + "p521-localhost", + ], + [ + "of another type, for a client that prefers it", + own, + { key: "ec10-key.pem", cert: "ec10-cert.pem" }, + "agent2", + version === "TLSv1.3" ? "agent10.example.com" : "agent2", + ], + ] as const) { + test(`a key ${which} in an ALPNCallback`, async () => { + const dir = to.key.startsWith("p521") ? fixtures : keys; + const context = tls.createSecureContext({ + key: readFileSync(join(dir, to.key)), + cert: readFileSync(join(dir, to.cert)), + }); + const got = await serve(emit, { ...from, ALPNCallback: selects(context) }, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: onBun ? bun : node, alpn: "h2" }); + }); + } + + // The key of agent11 has 512 bits, which Node.js refuses in a context. It is too short for the signature that + // the handshake picked for the key of agent2. + (onBun ? test : test.skip)("a shorter RSA key in an ALPNCallback leaves the certificate", async () => { + const context = tls.createSecureContext(identity("agent11")); + const got = await serve(emit, { ...own, ALPNCallback: selects(context) }, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: "agent2", alpn: "h2" }); + }); + + // With an RSA key exchange the client encrypts to the certificate that it got, and no other key can read that. + for (const ciphers of version === "TLSv1.2" ? [undefined, "AES128-GCM-SHA256"] : [undefined]) { + test(`a call after the answer, when the server's flight is out${ciphers ? ", RSA key exchange" : ""}`, async () => { + const SNICallback: SNICallback = function (_, answer) { + answer(null); + queueMicrotask(() => this.setKeyCert(other)); + }; + const options = { ...own, ...(ciphers && { ciphers }), SNICallback, ALPNProtocols: ["h2"] }; + const got = await serve(emit, options, dial => handshake(dial, version, ciphers ? { ciphers } : {})); + assert.deepStrictEqual(got, { cn: onBun ? "agent2" : "agent1", alpn: "h2" }); + }); + } + + // Bun takes the answer when the SNICallback returns. + test("a call after an answer with a context, in the SNICallback", async () => { + const SNICallback: SNICallback = function (_, answer) { + answer(null, third); + this.setKeyCert(other); + }; + const got = await serve(emit, { ...own, SNICallback, ALPNProtocols: ["h2"] }, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: onBun ? "agent3" : "agent1", alpn: "h2" }); + }); + + // On TLS 1.3 Node.js runs the ALPNCallback before the SNICallback, and Bun after it. The last call wins. + for (const [which, SNICallback] of [ + ["answered", ((_, answer) => answer(null, third)) as SNICallback], + ["set", applies(third)], + ] as const) { + for (const [door, open] of Object.entries(doors)) { + test(`setKeyCert() in an ALPNCallback, after the SNICallback ${which} a context, ${door}`, async () => { + const options = { ...own, SNICallback, ALPNCallback: selects(other) }; + const got = await serve(open, options, dial => handshake(dial, version)); + assert.deepStrictEqual(got, { cn: version === "TLSv1.3" && !onBun ? "agent3" : "agent1", alpn: "h2" }); + }); + } + } + + test("setKeyCert() in an ALPNCallback, for a name of addContext()", async () => { + const server = tls.createServer({ ...own, ALPNCallback: selects(other) }, socket => socket.on("error", () => {})); + server.addContext("a.test", identity("agent3")); + try { + const got = await handshake(await listen(server), version); + assert.deepStrictEqual(got, { cn: version === "TLSv1.3" && !onBun ? "agent3" : "agent1", alpn: "h2" }); + } finally { + server.close(); + } + }); + + // Here agent6 comes alone, and the two certificates above it are the `ca` of the context. Bun sends them, as a + // server with that context does. + test("the issuers that the context has as its CA", async () => { + const [certificate] = pem("agent6-cert.pem") + .toString() + .split(/(?<=-----END CERTIFICATE-----)/); + const context = tls.createSecureContext({ + key: pem("agent6-key.pem"), + cert: certificate, + ca: [pem("ca3-cert.pem"), pem("ca1-cert.pem")], + }); + const options = { ...own, SNICallback: applies(context) }; + assert.strictEqual(await serve(emit, options, dial => handshake(dial, version, {}, chainLength)), onBun ? 3 : 1); + }); + + // Node.js copies one certificate and one key, and leaves the server's certificates of other key types. Bun + // replaces every certificate of the connection with every one of the context. + describe("certificates of two key types", () => { + const rsa = identity("agent1"); + const ecdsa = identity("ec10"); + const both = (first: { key: Buffer; cert: Buffer }) => ({ + key: [first.key, ecdsa.key], + cert: [first.cert, ecdsa.cert], + }); + const toEach = async (options: tls.TlsOptions) => + serve(emit, { ...options, ALPNProtocols: ["h2"] }, async dial => ({ + "RSA only": await handshake(dial, version, only("RSA", version), c => served(c).cn).catch(refused), + "ECDSA only": await handshake(dial, version, only("ECDSA", version), c => served(c).cn).catch(refused), + })); + + test("in the context", async () => { + const context = tls.createSecureContext(both(rsa)); + assert.deepStrictEqual(await toEach({ ...identity("agent3"), SNICallback: applies(context) }), { + "RSA only": onBun ? "agent1" : "agent3", + "ECDSA only": "agent10.example.com", + }); + }); + + test("in the server", async () => { + assert.deepStrictEqual(await toEach({ ...both(identity("agent3")), SNICallback: applies(other) }), { + "RSA only": "agent1", + "ECDSA only": onBun ? "refused" : "agent10.example.com", + }); + }); + + test("in the context of the first of two calls", async () => { + const context = tls.createSecureContext(both(rsa)); + const SNICallback: SNICallback = function (_, answer) { + this.setKeyCert(context); + this.setKeyCert(third); + answer(null); + }; + assert.deepStrictEqual(await toEach({ ...own, SNICallback }), { + "RSA only": "agent3", + "ECDSA only": onBun ? "refused" : "agent10.example.com", + }); + }); + + test("an ECDSA and an Ed25519 one in the context", async () => { + const ed25519 = (part: string) => readFileSync(join(fixtures, `ed25519-${part}.pem`)); + const context = tls.createSecureContext({ + key: [ecdsa.key, ed25519("key")], + cert: [ecdsa.cert, ed25519("cert")], + }); + const got = await serve(emit, { ...own, SNICallback: applies(context) }, async dial => ({ + "Ed25519 only": await handshake(dial, version, { sigalgs: "ed25519" }, c => served(c).cn).catch(refused), + "ECDSA only": await handshake(dial, version, { sigalgs: "ecdsa_secp256r1_sha256" }, c => served(c).cn).catch( + refused, + ), + })); + assert.deepStrictEqual(got, { + "Ed25519 only": "ed25519-localhost", + "ECDSA only": onBun ? "agent10.example.com" : "refused", + }); + }); + + // The connections of such a server sign with one object for each key type, and Bun leaves that as it is. + test("in the server, for a call in an ALPNCallback", async () => { + const options = { ...both(identity("agent3")), ALPNCallback: selects(other) }; + const got = await serve(emit, options, dial => handshake(dial, version, only("RSA", version))); + assert.deepStrictEqual(got, { cn: onBun ? "agent3" : "agent1", alpn: "h2" }); + }); + }); + + // After the call the cipher suites and the signature algorithms are still the server's. Node.js also still has + // the server's RSA certificate for them. + test("a server that allows RSA only, and a context with an ECDSA certificate", async () => { + const policy = + version === "TLSv1.2" ? { ciphers: "ECDHE-RSA-AES128-GCM-SHA256" } : { sigalgs: "rsa_pss_rsae_sha256" }; + const options = { ...own, ...policy, SNICallback: applies(tls.createSecureContext(identity("ec10"))) }; + const got = await serve(emit, options, dial => + handshake(dial, version, {}, client => served(client).cn).catch(refused), + ); + assert.strictEqual(got, onBun ? "refused" : "agent2"); + }); + + // Node.js takes the certificate and then fails on the key, which leaves the connection without a certificate. + test("a context with a certificate and no key", async () => { + const context = tls.createSecureContext({ cert: pem("agent1-cert.pem") }); + let thrown: unknown; + const SNICallback: SNICallback = function (_, answer) { + try { + this.setKeyCert(context); + } catch (err) { + thrown = (err as NodeJS.ErrnoException).code ?? (err as Error).message; + } + answer(null); + }; + const got = await serve(emit, { ...own, SNICallback }, dial => + handshake(dial, version, {}, client => served(client).cn).catch(refused), + ); + assert.deepStrictEqual( + { got, thrown }, + onBun ? { got: "agent2", thrown: "SetKeyCert" } : { got: "refused", thrown: "ERR_SSL_PASSED_A_NULL_PARAMETER" }, + ); + }); + + // Bun gives the socket its connection after the constructor returns, and a call before that does nothing. + test("a call right after new TLSSocket()", async () => { + const front = net.createServer(raw => { + const socket = new tls.TLSSocket(raw, { ...own, isServer: true, ALPNProtocols: ["h2"] }); + socket.on("error", () => {}); + socket.setKeyCert(other); + }); + try { + const got = await handshake(await listen(front), version); + assert.deepStrictEqual(got, { cn: onBun ? "agent2" : "agent1", alpn: "h2" }); + } finally { + front.close(); + } + }); + + // Node.js ignores the call on a client socket. Bun applies it: the certificate that the client sends, and the CA + // that it verifies the server with. + test("a client socket", async () => { + const seen = Promise.withResolvers(); + const got = await serve( + emit, + { ...own, requestCert: true, rejectUnauthorized: false }, + async dial => { + const client = tls.connect({ + ...dial, + ...identity("agent1"), + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + client.on("error", seen.reject); + client.on("connect", () => client.setKeyCert(third)); + try { + return await seen.promise; + } finally { + client.destroy(); + } + }, + { onSecure: socket => seen.resolve(socket.getPeerCertificate().subject?.CN) }, + ); + assert.strictEqual(got, onBun ? "agent3" : "agent1"); + }); + + // The client keeps its own certificate then, and verifies the server with the CA of the context. + test("a client socket, and a context that has a CA only", async () => { + const trustsCa2 = tls.createSecureContext({ ca: pem("ca2-cert.pem") }); + const sent = Promise.withResolvers(); + const got = await serve( + emit, + { ...identity("agent1"), requestCert: true, rejectUnauthorized: false }, + async dial => { + const client = tls.connect({ + ...dial, + ...identity("agent3"), + ca: pem("ca1-cert.pem"), + checkServerIdentity: () => undefined, + rejectUnauthorized: false, + minVersion: version, + maxVersion: version, + }); + client.on("connect", () => client.setKeyCert(trustsCa2)); + try { + await once(client, "secureConnect"); + return { authorized: client.authorized, sent: await sent.promise }; + } finally { + client.destroy(); + } + }, + { onSecure: socket => sent.resolve(socket.getPeerCertificate().subject?.CN) }, + ); + assert.deepStrictEqual(got, { authorized: !onBun, sent: "agent3" }); + }); + }); +} + +// Only in Bun: when Node.js runs this file it must not spawn itself again. Node.js takes a .ts file from 22.18 and 23.6. +if (onBun) { + const node = Bun.which("node"); + const version = node + ? Bun.spawnSync({ cmd: [node, "-p", "process.versions.node"] }) + .stdout.toString() + .trim() + : ""; + const takesTypeScript = Bun.semver.satisfies(version, ">=22.18.0 <23.0.0 || >=23.6.0"); + describe("Node.js compatibility", () => { + (takesTypeScript ? test : test.skip)("all tests pass in Node.js", { timeout: 120_000 }, async () => { + await using proc = Bun.spawn({ + // A test that hangs in Node.js fails by name instead of running this one out of time. + cmd: [node!, "--test-timeout=15000", "--test-force-exit", import.meta.filename], + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + assert.deepStrictEqual({ exitCode, output: exitCode === 0 ? "" : stdout + stderr }, { exitCode: 0, output: "" }); + }); + }); +}