From e35621323dfdceb4b56a01bcbe8c9523591e495a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 10:06:35 -0500 Subject: [PATCH] Trust no certificate for an empty ca in TLS clients tls.connect, https.request and http2.connect verified the server against the default CAs when ca was an empty Buffer, because the runtime read a zero-length ca as none. The compiled calls now pass a null ca for none (an omitted ca, or an empty string, which Node reads as no ca) and a non-null one for any Buffer, and runtime option records read ca the same way, so an empty Buffer or array trusts no certificate, as in Node. The connection pool keys tell an empty ca from none. An empty array, which threw, is an empty trust store too. A runtime-valued empty string ca in http2.connect is refused instead of being read as an empty store. --- docs/content/limitations/tls-certificates.mdx | 3 + packages/compiler/src/backend/llvm/lib-abi.ts | 20 ++++ .../compiler/src/backend/llvm/lib-dispatch.ts | 7 +- .../compiler/src/backend/llvm/lib-http2.ts | 3 +- packages/compiler/src/backend/llvm/lib-tls.ts | 5 +- .../corpus/8176-tls-empty-context-ca.ts.json | 6 + .../corpus/8177-tls-empty-client-ca.ts.json | 6 + packages/runtime/src/scr_http2.c | 4 +- packages/runtime/src/scr_http2.h | 6 +- packages/runtime/src/scr_tls.c | 99 ++++++++++++----- packages/runtime/src/scr_tls.h | 2 +- tests/corpus/8176-tls-empty-context-ca.ts | 73 ++++++++++++ tests/corpus/8177-tls-empty-client-ca.ts | 104 ++++++++++++++++++ .../8176-tls-empty-context-ca.ts.json | 1 + .../8177-tls-empty-client-ca.ts.json | 1 + 15 files changed, 300 insertions(+), 40 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json create mode 100644 tests/corpus/8176-tls-empty-context-ca.ts create mode 100644 tests/corpus/8177-tls-empty-client-ca.ts create mode 100644 tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json create mode 100644 tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json diff --git a/docs/content/limitations/tls-certificates.mdx b/docs/content/limitations/tls-certificates.mdx index bbe4ceaf9..639dd3d83 100644 --- a/docs/content/limitations/tls-certificates.mdx +++ b/docs/content/limitations/tls-certificates.mdx @@ -2,6 +2,9 @@ On a TLS socket held in an `unknown` or `any` value, or in an untyped JavaScript parameter, they throw an error that names the call as not supported. Client certificates follow Node: `tls.createServer`, `https.createServer`, and `new tls.TLSSocket` take `requestCert`, `rejectUnauthorized`, and `ca`, and `tls.connect` and `https.request` take `cert` and `key`. A server's `authorized` and `authorizationError` report the client certificate's verification as Node does, and a server, client, or `tls.createSecureContext` given a `ca` also sends the chain it completes, as OpenSSL's automatic chain does. +A server verifies client certificates against the `ca` of the context it serves, including contexts from `addContext()` and `setSecureContext()`: an empty `ca` (an empty Buffer or array) trusts no certificate, and an omitted `ca` trusts the default CAs. +The same holds for a client's `ca` in `tls.connect`, `https.request`, and `http2.connect`, where an empty string is no `ca`, as in Node. +In `http2.connect`, a `ca` held in an `unknown` or `any` value that is an empty string is refused. `requestCert` beside a TLSSocket's `secureContext` or an `SNICallback`, client certificates on http2 secure servers, and a `cert` without a `key` (or the reverse) are refused. `server.addContext()` and `server.setSecureContext()` follow Node on tls and https servers, including wildcard names, which match one label without regard to case. A name with regular-expression groups or alternation, or one outside ASCII, is refused, as are both methods on a server that is not a tls or https server. diff --git a/packages/compiler/src/backend/llvm/lib-abi.ts b/packages/compiler/src/backend/llvm/lib-abi.ts index 6917d8256..ea5ab82a2 100644 --- a/packages/compiler/src/backend/llvm/lib-abi.ts +++ b/packages/compiler/src/backend/llvm/lib-abi.ts @@ -73,6 +73,26 @@ export function rawBytes(host: LlvmEmitterContext, value: LlValue): AbiArgument[ return [ptr(data), { type: host.sizeType, name: length }]; } +/** A TLS client's `ca` as rawBytes, with a null pointer for none: an empty + * string, which Node reads as no ca (and which an omitted ca lowers to). + * A Buffer's pointer is never null, so an empty one is an empty trust + * store. */ +export function caBytes(host: LlvmEmitterContext, value: LlValue): AbiArgument[] { + const B = host.B; + const [data, length] = rawBytes(host, value); + const test = B.tmp(), + pointer = B.tmp(); + if (value.type.kind === "string") { + B.line(`${test} = icmp eq ${host.sizeType} ${length!.name}, 0`); + B.line(`${pointer} = select i1 ${test}, ptr null, ptr ${data!.name}`); + } else { + // An empty Buffer's data pointer may be null; its object stands in. + B.line(`${test} = icmp eq ptr ${data!.name}, null`); + B.line(`${pointer} = select i1 ${test}, ptr ${value.name}, ptr ${data!.name}`); + } + return [ptr(pointer), length!]; +} + export function callbackType(value: LlValue): IrType & { kind: "func" } { if (value.type.kind !== "func") throw new InternalCompilerError("runtime callback must be a function"); diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index 9cd11feb1..e371b10a4 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -22,7 +22,7 @@ import { } from "./lib-shared.js"; import { isDynUndefined } from "../../ir/runtime-features.js"; import { DYN_KIND } from "./dyn.js"; -import { rawBytes } from "./lib-abi.js"; +import { caBytes } from "./lib-abi.js"; import { emitInlineAtomicsOp } from "./expr-bytes.js"; import { regexLiteralCaptures } from "./regex-matchers.js"; @@ -595,7 +595,8 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll // The https URL row is the http one with the TLS entry point — same // three arguments, same response-callback adapter. The https options // row is wider: rejectUnauthorized stays an i1, while its ScrStr or - // ScrBytes CA value expands to the runtime's raw pointer + length. + // ScrBytes CA value expands to the runtime's raw pointer + length + // (caBytes: a null pointer for none). const isTls = e.fn.startsWith("https."); const isUrl = e.fn.includes("requestUrl"); const isTlsOptions = isTls && !isUrl; @@ -629,7 +630,7 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll if (isTlsOptions) { callArgs = [ ...callArgs.slice(0, 8), - ...rawBytes(host, args[8]!).map((arg) => `${arg.type} ${arg.name}`), + ...caBytes(host, args[8]!).map((arg) => `${arg.type} ${arg.name}`), ]; host.declare( `declare ptr @scr_https_request(ptr, double, ptr, ptr, double, ptr, i1 zeroext, i1 zeroext, ptr, ${host.sizeType}, ptr, ptr)`, diff --git a/packages/compiler/src/backend/llvm/lib-http2.ts b/packages/compiler/src/backend/llvm/lib-http2.ts index 321f1d005..d750fb7c5 100644 --- a/packages/compiler/src/backend/llvm/lib-http2.ts +++ b/packages/compiler/src/backend/llvm/lib-http2.ts @@ -9,6 +9,7 @@ import { callbackType, finishRuntimeCall, ptr, + caBytes, rawBytes, requestHandler, } from "./lib-abi.js"; @@ -204,7 +205,7 @@ export function emitHttp2LibCall(host: LlvmEmitterContext, e: LibCallExpr): LlVa ); return finishRuntimeCall(host, e, "scr_http2_connect", [ ...a.slice(0, 2), - ...rawBytes(host, args[2]!), + ...caBytes(host, args[2]!), ...handler, ]); } diff --git a/packages/compiler/src/backend/llvm/lib-tls.ts b/packages/compiler/src/backend/llvm/lib-tls.ts index 307aeb3dd..dbe492180 100644 --- a/packages/compiler/src/backend/llvm/lib-tls.ts +++ b/packages/compiler/src/backend/llvm/lib-tls.ts @@ -8,6 +8,7 @@ import { finishRuntimeCall, nullableReference, ptr, + caBytes, rawBytes, requestHandler, } from "./lib-abi.js"; @@ -184,7 +185,7 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu ); return finishRuntimeCall(host, e, "scr_https_request_agent", [ ...a.slice(0, 8), - ...rawBytes(host, args[8]!), + ...caBytes(host, args[8]!), a[9]!, ...handler, ]); @@ -204,7 +205,7 @@ export function emitTlsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValu ["ptr", "ptr"], ); // Evaluate arguments once before selecting which transport consumes the callback. - const pem = rawBytes(host, args[9]!); + const pem = caBytes(host, args[9]!); const B = host.B, tls = B.newLabel("request.tls"), plain = B.newLabel("request.plain"), diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json new file mode 100644 index 000000000..536027066 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8176-tls-empty-context-ca.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8176-tls-empty-context-ca.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json new file mode 100644 index 000000000..b28587594 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8177-tls-empty-client-ca.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8177-tls-empty-client-ca.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_http2.c b/packages/runtime/src/scr_http2.c index 55d880c01..489946a12 100644 --- a/packages/runtime/src/scr_http2.c +++ b/packages/runtime/src/scr_http2.c @@ -2617,11 +2617,11 @@ static void scr_h2_on_established(void *ctx) { * TLS client transport with ALPN ["h2"] (scr_tls.c — linked whenever * this unit is): the same preface/SETTINGS buffer through the handshake * and everything after establishment is protocol-identical. `ca` - * replaces the client trust anchors (len 0 = none) and + * replaces the client trust anchors (NULL = none) and * reject_unauthorized false disables the verify gate — the local-CA * self-connect shapes. Throws catchably on unparsable input. */ ScrH2Session *scr_http2_connect(ScrStr *url /*borrowed*/, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrH2ConnectFn fn) { const char *p = url->data; const char *sep = strstr(p, "://"); diff --git a/packages/runtime/src/scr_http2.h b/packages/runtime/src/scr_http2.h index 25e236e6f..c4ce850d0 100644 --- a/packages/runtime/src/scr_http2.h +++ b/packages/runtime/src/scr_http2.h @@ -69,7 +69,7 @@ void scr_http2_server_on_session(ScrNetServer *s, ScrClosure *cb /*moves*/, ScrH * reject_unauthorized false disables the verify gate — the local-CA * self-connect shapes); throws catchably on bad input */ ScrH2Session *scr_http2_connect(ScrStr *url /*borrowed*/, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrH2ConnectFn fn); /* +1 */ /* end_stream: -1 = the method's payload-meaningless default, 0/1 explicit */ @@ -173,7 +173,7 @@ ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn); /* +1 */ /* The URL-string form (https.get('https://host/path')): scr_http.c's * shared parse, then the options form's dial with Node's no-options @@ -187,7 +187,7 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, const struct ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn); /* +1 */ /* The fetch unit's https leg (defined in scr_tls.c): a client transport diff --git a/packages/runtime/src/scr_tls.c b/packages/runtime/src/scr_tls.c index 2f448a417..e764926b2 100644 --- a/packages/runtime/src/scr_tls.c +++ b/packages/runtime/src/scr_tls.c @@ -253,7 +253,10 @@ struct ScrSecureCtx { mbedtls_x509_crt cert; mbedtls_pk_context pk; /* the ca option: a socket created from this context (a TLSSocket's - * secureContext) sends the chain it completes */ + * secureContext) sends the chain it completes, and a server that serves + * the context verifies client certificates against it. has_ca tells an + * empty ca (an empty trust store) from none (the default store). */ + bool has_ca; mbedtls_x509_crt ca; }; @@ -573,6 +576,9 @@ static void scr_tls_srv_client_auth(ScrTlsSrv *s, const ScrTlsClientAuth *auth) typedef struct ScrTlsCli { mbedtls_ssl_config conf; + /* the ca option, which replaces the default store; has_ca tells an + * empty ca (an empty trust store) from none */ + bool has_ca; mbedtls_x509_crt ca; char *hostname; /* the SNI name; NULL sends none (an IP literal, servername: "") */ char *verify_name; /* the name the leaf is checked against; NULL fails the check */ @@ -696,6 +702,7 @@ static ScrTlsCli *scr_tls_cli_new2(ScrStr *host, bool reject_unauthorized, const mbedtls_ssl_conf_rng(&c->conf, mbedtls_ctr_drbg_random, &scr_tls_drbg); c->verify_recorded = reject_unauthorized || verify_optional; c->reject = reject_unauthorized; + c->has_ca = ca != NULL; if (ca_len > 0) { int r = scr_tls_parse_crt(&c->ca, ca, ca_len); if (r != 0) scr_tls_bad_material("ca certificate", r); @@ -705,7 +712,7 @@ static ScrTlsCli *scr_tls_cli_new2(ScrStr *host, bool reject_unauthorized, const } else { mbedtls_ssl_conf_authmode(&c->conf, reject_unauthorized ? MBEDTLS_SSL_VERIFY_REQUIRED : MBEDTLS_SSL_VERIFY_OPTIONAL); - mbedtls_ssl_conf_ca_chain(&c->conf, ca_len > 0 ? &c->ca : scr_tls_system_ca(), NULL); + mbedtls_ssl_conf_ca_chain(&c->conf, c->has_ca ? &c->ca : scr_tls_system_ca(), NULL); } /* The host is the verified name; it is also the SNI name unless it is * an IP literal. */ @@ -799,6 +806,9 @@ typedef struct ScrTlsSock { unsigned char *hello; /* the raw ClientHello flight, replayed at DONE */ size_t hello_len, hello_cap; ScrSecureCtx *sni_ctx; /* owned; the answer (NULL = default cert) */ + /* scr_tls_f_cert verified client certificates against sni_ctx's ca + * (or the default store without one), not the server's */ + bool ctx_verifies; /* new tls.TLSSocket(socket, { isServer: true }): the program holds this * socket before the handshake, so a failed handshake is its 'error' * (tls.createServer's sockets fail without one) */ @@ -1889,8 +1899,8 @@ static ScrStr *scr_tls_pool_key(ScrStr *host, bool reject_unauthorized, const ch uint64_t h = 1469598103934665603ull; for (size_t i = 0; i < ca_len; i++) h = (h ^ (unsigned char)ca[i]) * 1099511628211ull; char buf[64]; - int n = snprintf(buf, sizeof buf, "%d:%zu:%016llx:", reject_unauthorized ? 1 : 0, ca_len, - (unsigned long long)h); + int n = snprintf(buf, sizeof buf, "%d:%s%zu:%016llx:", reject_unauthorized ? 1 : 0, + ca != NULL ? "" : "-", ca_len, (unsigned long long)h); ScrJsonBuf b; scr_jb_init(&b); scr_jb_write(&b, buf, (size_t)n); @@ -2148,7 +2158,7 @@ ScrHttpClientReq *scr_https_request(ScrStr *host /*borrowed*/, double port, ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { ScrStr *servername = scr_https_take_servername(); ScrTlsCli *cli = scr_tls_cli_for(host, reject_unauthorized, ca, ca_len, servername); @@ -2189,7 +2199,7 @@ ScrHttpClientReq *scr_https_request_agent(ScrStr *host /*borrowed*/, double port ScrStr *path /*borrowed*/, ScrStr *method /*borrowed*/, double timeout_ms, ScrArr *header_pairs /*borrowed*/, bool auto_end, bool reject_unauthorized, - const char *ca /*borrowed, len 0 = none*/, size_t ca_len, + const char *ca /*borrowed, NULL = none*/, size_t ca_len, const ScrDyn *agent /*borrowed*/, ScrClosure *cb /*moves, nullable*/, ScrHttpRespFn fn) { ScrStr *servername = scr_https_take_servername(); @@ -2242,6 +2252,14 @@ static bool scr_tls_dyn_truthy(const ScrDyn *v) { * the exception pending. `what` names the fence ("a tls.createServer * 'cert' option"). */ static ScrBytes *scr_tls_pem_dyn(const ScrDyn *v, const char *what, bool concat) { + if (v->kind == SCR_DYN_TYPED_REF) { + /* a typed array (an array literal in a typed record): its entries */ + ScrDyn *plain = scr_dyn_typed_ref_materialize(v); + if (plain == NULL) return NULL; + ScrBytes *b = scr_tls_pem_dyn(plain, what, concat); + scr_dyn_release(plain); + return b; + } if (v->kind == SCR_DYN_STR) { ScrBytes *b = scr_bytes_new(SCR_BYTES_U8, (double)v->v.str->len); memcpy(b->data, v->v.str->data, v->v.str->len); @@ -2292,6 +2310,33 @@ static ScrBytes *scr_tls_pem_dyn(const ScrDyn *v, const char *what, bool concat) return NULL; } +/* A `ca` option from a runtime value: NULL for none (an empty string, + * which Node reads as no ca), empty bytes for an empty Buffer or array + * (an empty trust store), else the PEM bundle (+1). False with the + * exception pending. */ +static bool scr_tls_ca_dyn(const ScrDyn *v, const char *what, ScrBytes **out) { + *out = NULL; + ScrDyn *plain = v->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(v) : NULL; + if (v->kind == SCR_DYN_TYPED_REF && plain == NULL) return false; + const ScrDyn *ca = plain != NULL ? plain : v; + bool ok = true; + if (ca->kind == SCR_DYN_ARR && ca->v.arr.len == 0) { + *out = scr_bytes_new(SCR_BYTES_U8, 0); + } else if (ca->kind != SCR_DYN_STR || ca->v.str->len > 0) { + *out = scr_tls_pem_dyn(ca, what, true); + ok = *out != NULL; + } + scr_dyn_release(plain); + return ok; +} + +/* A client's ca for scr_tls_cli_new2: NULL for none, and never NULL for a + * given ca, even an empty one. */ +static const char *scr_tls_ca_data(const ScrBytes *ca) { + if (ca == NULL) return NULL; + return ca->data != NULL ? (const char *)ca->data : ""; +} + /* https.request/get with a runtime options record: the http walk, then * the TLS options the literal https path lowers (rejectUnauthorized, ca, * servername) and a client certificate (cert and key). Other tls.connect @@ -2330,8 +2375,7 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au if (r != NULL && r->kind == SCR_DYN_BOOL && !r->v.b) reject = false; const ScrDyn *c = ok ? scr_dyn_obj_get(rec, "ca", 2) : NULL; if (c != NULL && c->kind != SCR_DYN_UNDEF && c->kind != SCR_DYN_NULL) { - ca = scr_tls_pem_dyn(c, "an https.request 'ca' option", true); - if (ca == NULL) ok = false; + ok = scr_tls_ca_dyn(c, "an https.request 'ca' option", &ca); } const ScrDyn *oc = ok ? scr_dyn_obj_get(rec, "cert", 4) : NULL; if (oc != NULL && oc->kind != SCR_DYN_UNDEF && oc->kind != SCR_DYN_NULL) { @@ -2366,7 +2410,7 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au if (cb) scr_closure_release(cb); return NULL; } - const char *ca_data = ca ? (const char *)ca->data : NULL; + const char *ca_data = scr_tls_ca_data(ca); size_t ca_len = ca ? ca->len : 0; ScrTlsCli *cli = scr_tls_cli_for(o.host, reject, ca_data, ca_len, servername); ScrStr *key = scr_tls_pool_key(o.host, reject, ca_data, ca_len, servername); @@ -2397,13 +2441,17 @@ ScrHttpClientReq *scr_https_request_dyn(const ScrDyn *opts /*borrowed*/, bool au /* The literal path's runtime-valued cert/key extraction (the compiler's * tls.pemDyn libCall): `what` arrives precomposed. undefined throws — the - * literal walk required the member's presence. */ + * literal walk required the member's presence — and so does an empty + * string, which Node reads as no material while empty bytes here are an + * empty ca. */ ScrBytes *scr_tls_pem_from_dyn(const ScrDyn *v, const char *what) { - if (v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL) { + bool empty = v->kind == SCR_DYN_STR && v->v.str->len == 0; + if (v->kind == SCR_DYN_UNDEF || v->kind == SCR_DYN_NULL || empty) { ScrJsonBuf b; scr_jb_init(&b); scr_jb_puts(&b, what); - scr_jb_puts(&b, " holding undefined has no static lowering — pass PEM material (a string or a Buffer)"); + scr_jb_puts(&b, empty ? " holding an empty string" : " holding undefined"); + scr_jb_puts(&b, " has no static lowering — pass PEM material (a string or a Buffer)"); scr_throw_error(SCR_ERR_ERROR, scr_jb_finish(&b)); return NULL; } @@ -2565,14 +2613,7 @@ static bool scr_tls_srv_opts_walk_ex(const ScrDyn *opts, const char *api, ScrByt char what[96]; snprintf(what, sizeof what, "a %s 'ca' option", api); scr_bytes_release(auth->ca); - /* An empty string is no ca, as Node reads it; an empty Buffer is - * an empty trust store. */ - if (v->kind == SCR_DYN_STR && v->v.str->len == 0) { - auth->ca = NULL; - } else { - auth->ca = scr_tls_pem_dyn(v, what, true); - if (auth->ca == NULL) ok = false; - } + if (!scr_tls_ca_dyn(v, what, &auth->ca)) ok = false; } continue; } @@ -2658,6 +2699,7 @@ ScrSecureCtx *scr_tls_create_secure_context_dyn(const ScrDyn *opts /*borrowed*/) int r = scr_tls_parse_crt(&c->ca, (const char *)auth.ca->data, auth.ca->len); if (r != 0) scr_tls_bad_material("ca certificate", r); } + c->has_ca = true; scr_bytes_release(auth.ca); } scr_bytes_release(cert); @@ -2810,8 +2852,10 @@ static int scr_tls_f_cert(mbedtls_ssl_context *ssl) { scr_tls_chained_copy(&t->served_chain, &pick->cert, store); own = &t->served_chain; } - if (s->request_cert) - mbedtls_ssl_set_hs_ca_chain(ssl, pick->ca.raw.len > 0 ? &pick->ca : scr_tls_system_ca(), NULL); + if (s->request_cert) { + t->ctx_verifies = true; + mbedtls_ssl_set_hs_ca_chain(ssl, pick->has_ca ? &pick->ca : scr_tls_system_ca(), NULL); + } return mbedtls_ssl_set_hs_own_cert(ssl, own, &pick->pk); } @@ -3261,8 +3305,7 @@ static ScrNetSocket *scr_tls_connect_plain(double port, ScrStr *host /*borrowed, } if (strcmp(e->key, "ca") == 0) { scr_bytes_release(ca); - ca = scr_tls_pem_dyn(v, "a tls.connect 'ca' option", true); - if (ca == NULL) ok = false; + if (!scr_tls_ca_dyn(v, "a tls.connect 'ca' option", &ca)) ok = false; continue; } if (strcmp(e->key, "cert") == 0 || strcmp(e->key, "key") == 0) { @@ -3386,8 +3429,7 @@ static ScrNetSocket *scr_tls_connect_plain(double port, ScrStr *host /*borrowed, if (dial_addr) scr_str_release(dial_addr); } ScrTlsCli *cli = scr_tls_cli_new2(verify_host, reject, - ca != NULL ? (const char *)ca->data : NULL, - ca != NULL ? ca->len : 0, !reject); + scr_tls_ca_data(ca), ca != NULL ? ca->len : 0, !reject); if (servername != NULL) scr_tls_cli_set_servername(cli, servername); if (own_cert != NULL) scr_tls_cli_set_own(cli, own_cert, own_key); scr_bytes_release(own_cert); @@ -3441,7 +3483,7 @@ void scr_tls_server_wrap_h2(ScrNetServer *s, const char *cert, size_t cert_len, /* The h2-over-TLS CLIENT wrap (http2.connect with an https authority): * a client transport offering ALPN ["h2"] — SNI/verification against - * the authority host; `ca` replaces the trust anchors ("" = none) and + * the authority host; `ca` replaces the trust anchors (NULL = none) and * reject_unauthorized false records like tls.connect's. The session's * preface/SETTINGS ride the socket's write buffer until establishment. */ void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, @@ -3595,7 +3637,8 @@ void scr_tls_sock_each_trusted(ScrNetSocket *s, bool (*visit)(void *ctx, const u void *ctx) { ScrTlsSock *t = scr_tls_sock_of(s); if (t == NULL || (t->cli == NULL && t->srv == NULL)) return; - const mbedtls_x509_crt *c = t->cli != NULL ? (t->cli->ca.raw.len > 0 ? &t->cli->ca : scr_tls_system_ca()) + const mbedtls_x509_crt *c = t->cli != NULL ? (t->cli->has_ca ? &t->cli->ca : scr_tls_system_ca()) + : t->ctx_verifies ? (t->sni_ctx->has_ca ? &t->sni_ctx->ca : scr_tls_system_ca()) : t->srv->has_ca ? &t->srv->ca : scr_tls_system_ca(); for (; c != NULL && c->raw.len > 0; c = c->next) diff --git a/packages/runtime/src/scr_tls.h b/packages/runtime/src/scr_tls.h index 94bf41d33..8e044f12d 100644 --- a/packages/runtime/src/scr_tls.h +++ b/packages/runtime/src/scr_tls.h @@ -85,7 +85,7 @@ void scr_tls_sock_on_secure(ScrNetSocket *s, ScrClosure *cb /*moves*/, bool once * every http2-using binary links the TLS unit (the moduleUsesTls * switch), so no registration indirection is needed. */ void scr_tls_server_wrap_h2(ScrNetServer *s, const char *cert, size_t cert_len, const char *key, size_t key_len, ScrNetNativeConnFn h2_conn, void *h2_ctx /*moves*/, void (*h2_ctx_free)(void *), ScrClosure *sni_cb /*moves, nullable*/, void *sni_answer_fn); -void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, bool reject_unauthorized, const char *ca /*borrowed, len 0 = none*/, size_t ca_len); +void scr_tls_h2_client_wrap(ScrNetSocket *sock, ScrStr *host /*borrowed*/, bool reject_unauthorized, const char *ca /*borrowed, NULL = none*/, size_t ca_len); /* ── tls.SecureContext + the SNI callback (scr_tls.c) ───────────────── * A SecureContext is an opaque refcounted parsed cert/key pair diff --git a/tests/corpus/8176-tls-empty-context-ca.ts b/tests/corpus/8176-tls-empty-context-ca.ts new file mode 100644 index 000000000..510c9881a --- /dev/null +++ b/tests/corpus/8176-tls-empty-context-ca.ts @@ -0,0 +1,73 @@ +// An explicitly empty ca is an empty trust store for client certificates, +// in the server's options, in setSecureContext and in addContext, even when +// the client certificate's issuer is a default CA +// (tls.setDefaultCACertificates); an omitted ca trusts the default CAs. +// Each server has requestCert, with rejectUnauthorized false (the server's +// verdict) and true (the client is dropped). +import { readFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), cert = r("localhost.pem"), key = r("localhost-key.pem"); +const alt = r("alt.pem"), altKey = r("alt-key.pem"); +const client = r("client.pem"), clientKey = r("client-key.pem"); + +tls.setDefaultCACertificates([ca.toString()]); + +function listen(server: tls.Server): Promise { + return new Promise((res) => server.listen(0, () => res((server.address() as AddressInfo).port))); +} + +type Handler = (s: tls.TLSSocket) => void; + +async function run(label: string, make: (h: Handler) => tls.Server, servername: string): Promise { + const events: string[] = []; + const server = make((s) => { + events.push(`server authorized=${s.authorized} error=${s.authorizationError}`); + s.end("ok"); + }); + const port = await listen(server); + await new Promise((done) => { + const c = tls.connect({ port, servername, ca, cert: client, key: clientKey }); + c.on("secureConnect", () => events.push("client secureConnect")); + c.on("data", (d: Buffer) => events.push(`client data ${d}`)); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`client error ${e.code}`)); + c.on("close", () => setTimeout(done, 50)); + }); + console.log(label, "|", events.join(" ; ")); + await new Promise((res) => server.close(() => res())); +} + +for (const reject of [false, true]) { + const opts = { cert, key, requestCert: true, rejectUnauthorized: reject }; + const tag = reject ? "reject" : "request"; + await run(`${tag} omitted`, (h) => tls.createServer(opts, h), "localhost"); + await run(`${tag} empty array`, (h) => tls.createServer({ ...opts, ca: [] }, h), "localhost"); + await run(`${tag} setSecureContext empty`, (h) => { + const server = tls.createServer(opts, h); + server.setSecureContext({ cert, key, ca: [] }); + return server; + }, "localhost"); + await run(`${tag} setSecureContext omitted`, (h) => { + const server = tls.createServer({ ...opts, ca: [] }, h); + server.setSecureContext({ cert, key }); + return server; + }, "localhost"); + await run(`${tag} addContext empty array`, (h) => { + const server = tls.createServer(opts, h); + server.addContext("alt.localhost", { cert: alt, key: altKey, ca: [] }); + return server; + }, "alt.localhost"); + await run(`${tag} addContext empty Buffer`, (h) => { + const server = tls.createServer(opts, h); + server.addContext("alt.localhost", tls.createSecureContext({ cert: alt, key: altKey, ca: Buffer.alloc(0) })); + return server; + }, "alt.localhost"); + await run(`${tag} addContext omitted`, (h) => { + const server = tls.createServer({ ...opts, ca: [] }, h); + server.addContext("alt.localhost", { cert: alt, key: altKey }); + return server; + }, "alt.localhost"); +} diff --git a/tests/corpus/8177-tls-empty-client-ca.ts b/tests/corpus/8177-tls-empty-client-ca.ts new file mode 100644 index 000000000..0fb596ddb --- /dev/null +++ b/tests/corpus/8177-tls-empty-client-ca.ts @@ -0,0 +1,104 @@ +// An explicitly empty ca is an empty trust store for a client's view of +// the server certificate, in tls.connect, https.request and http2.connect, +// even when the server certificate's issuer is a default CA +// (tls.setDefaultCACertificates); an omitted ca, or an empty string, trusts +// the default CAs. +import { readFileSync } from "node:fs"; +import * as http2 from "node:http2"; +import * as https from "node:https"; +import type { AddressInfo } from "node:net"; +import * as tls from "node:tls"; + +const D = "tests/fixtures/server/certs"; +const r = (f: string): Buffer => readFileSync(`${D}/${f}`); +const ca = r("ca.pem"), key = r("localhost-key.pem"); +// The servers send their issuer too, so each client sees the same chain. +const cert = Buffer.concat([r("localhost.pem"), ca]); + +tls.setDefaultCACertificates([ca.toString()]); + +function listen(server: tls.Server | https.Server | http2.Http2SecureServer): Promise { + return new Promise((res) => server.listen(0, () => res((server.address() as AddressInfo).port))); +} + +async function watch(label: string, c: tls.TLSSocket): Promise { + const events: string[] = []; + await new Promise((done) => { + c.on("secureConnect", () => { + events.push(`secureConnect authorized=${c.authorized} error=${c.authorizationError}`); + c.end(); + }); + c.on("error", (e: NodeJS.ErrnoException) => events.push(`error ${e.code}`)); + c.on("close", () => done()); + }); + console.log("tls.connect", label, "|", events.join(" ; ")); +} + +{ + const server = tls.createServer({ cert, key }, (s) => s.end()); + const port = await listen(server); + const host = "localhost"; + await watch("omitted", tls.connect({ port, host })); + await watch("empty string", tls.connect({ port, host, ca: "" })); + await watch("empty Buffer", tls.connect({ port, host, ca: Buffer.alloc(0) })); + await watch("empty array", tls.connect({ port, host, ca: [] })); + await watch( + "empty Buffer, rejectUnauthorized false", + tls.connect({ port, host, ca: Buffer.alloc(0), rejectUnauthorized: false }), + ); + await watch("empty array, rejectUnauthorized false", tls.connect({ port, host, ca: [], rejectUnauthorized: false })); + server.close(); +} + +function report(label: string, req: ReturnType, done: () => void): void { + req.on("response", (res) => { + console.log("https.request", label, "| status", res.statusCode); + res.resume(); + res.on("end", done); + }); + req.on("error", (e: NodeJS.ErrnoException) => { + console.log("https.request", label, "| error", e.code); + done(); + }); + req.end(); +} + +{ + const server = https.createServer({ cert, key }, (_req, res) => res.end("ok")); + const port = await listen(server); + await new Promise((done) => + report("omitted", https.request({ host: "localhost", port, path: "/", agent: false }), done), + ); + await new Promise((done) => + report("empty string", https.request({ host: "localhost", port, path: "/", agent: false, ca: "" }), done), + ); + await new Promise((done) => + report( + "empty Buffer", + https.request({ host: "localhost", port, path: "/", agent: false, ca: Buffer.alloc(0) }), + done, + ), + ); + const options: unknown = { host: "localhost", port, path: "/", agent: false, ca: [] }; + await new Promise((done) => + report("empty array in a runtime record", https.request(options as https.RequestOptions), done), + ); + server.close(); +} + +{ + const server = http2.createSecureServer({ cert, key }, (_req, res) => res.end("ok")); + const port = await listen(server); + for (const [label, ca] of [["omitted", undefined], ["empty Buffer", Buffer.alloc(0)]] as const) { + await new Promise((done) => { + const session = ca === undefined ? http2.connect(`https://localhost:${port}`) : http2.connect(`https://localhost:${port}`, { ca }); + session.on("connect", () => { + console.log("http2.connect", label, "| connect"); + session.close(); + }); + session.on("error", (e: NodeJS.ErrnoException) => console.log("http2.connect", label, "| error", e.code)); + session.on("close", () => done()); + }); + } + server.close(); +} diff --git a/tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json b/tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json new file mode 100644 index 000000000..2f8507d0f --- /dev/null +++ b/tests/harness/corpus-costs/8176-tls-empty-context-ca.ts.json @@ -0,0 +1 @@ +{ "plain": 7322, "san": 94064 } diff --git a/tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json b/tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json new file mode 100644 index 000000000..d7df308c8 --- /dev/null +++ b/tests/harness/corpus-costs/8177-tls-empty-client-ca.ts.json @@ -0,0 +1 @@ +{ "plain": 4335, "san": 61880 }