From ed17e5fb05090c62e99bc92ae94e4dbbde5acf24 Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Tue, 9 Jun 2026 07:24:05 -0400 Subject: [PATCH] net.http: add ALPN negotiation to the Windows SChannel backend (#27395) * net.http: add ALPN negotiation to the Windows SChannel backend First half of vlang/v#27383: give the SChannel (vschannel) TLS backend the ability to advertise ALPN protocols and read the protocol the server selected, reaching parity with the mbedtls/OpenSSL backends (#27343) on the negotiation primitive. thirdparty/vschannel/vschannel.c: - TlsContext gains an ALPN protocol list to advertise and a slot for the negotiated protocol. - perform_client_handshake() passes a SECBUFFER_APPLICATION_PROTOCOLS input buffer (SEC_APPLICATION_PROTOCOLS / SecApplicationProtocolNegotiationExt_ALPN) into the ClientHello when a list is configured; the path is unchanged when it is not, so existing HTTP/1.1 requests are byte-identical. - After the handshake, SECPKG_ATTR_APPLICATION_PROTOCOL is queried and stored. - New C entry points: vschannel_set_alpn(), vschannel_get_alpn(), and vschannel_alpn_probe() (handshake-only, no application data). vlib/net/http (Windows only): - vschannel_alpn_windows.c.v exposes the C API, an alpn_wire() encoder, and schannel_alpn_probe(). - vschannel_alpn_windows_test.v: a wire-encoding unit test plus `-d network` tests that probe a public HTTP/2 server and assert `h2` is negotiated (and that offering only http/1.1 falls back). This deliberately does NOT change fetch(): the one-shot vschannel request() path still speaks HTTP/1.1, so advertising `h2` in real requests waits for the HTTP/2-driver wiring (the second half of #27383). It satisfies the issue's first acceptance criterion (a negotiated_alpn()-equivalent on SChannel). Co-Authored-By: Claude Opus 4.8 * thirdparty/vschannel: add ALPN type shim for older SDK headers (tcc) tcc bundles Windows SDK headers that predate SChannel ALPN, so the ALPN structs, enums and constants were undeclared. Define them when the SDK headers do not, matching the official layout exactly. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Richard Wheeler Co-authored-by: Claude Opus 4.8 Co-authored-by: Alexander Medvednikov --- thirdparty/vschannel/vschannel.c | 187 +++++++++++++++++++- thirdparty/vschannel/vschannel.h | 6 + vlib/net/http/vschannel_alpn_windows.c.v | 56 ++++++ vlib/net/http/vschannel_alpn_windows_test.v | 36 ++++ 4 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 vlib/net/http/vschannel_alpn_windows.c.v create mode 100644 vlib/net/http/vschannel_alpn_windows_test.v diff --git a/thirdparty/vschannel/vschannel.c b/thirdparty/vschannel/vschannel.c index e2e13de2d..62d8e5652 100644 --- a/thirdparty/vschannel/vschannel.c +++ b/thirdparty/vschannel/vschannel.c @@ -1,6 +1,55 @@ #include #include +// ALPN (RFC 7301) compatibility shim. Older toolchain headers (notably the +// ones bundled with tcc) predate the SChannel ALPN additions, so the structs, +// enums and constants below are missing there. Define them ourselves when the +// SDK headers did not. SECPKG_ATTR_APPLICATION_PROTOCOL guards the schannel.h +// types; SECBUFFER_APPLICATION_PROTOCOLS guards the sspi.h buffer constant. +#ifndef ANYSIZE_ARRAY +#define ANYSIZE_ARRAY 1 +#endif + +#ifndef SECPKG_ATTR_APPLICATION_PROTOCOL +#define SECPKG_ATTR_APPLICATION_PROTOCOL 35 + +typedef enum _SEC_APPLICATION_PROTOCOL_NEGOTIATION_EXT { + SecApplicationProtocolNegotiationExt_None, + SecApplicationProtocolNegotiationExt_NPN, + SecApplicationProtocolNegotiationExt_ALPN +} SEC_APPLICATION_PROTOCOL_NEGOTIATION_EXT, *PSEC_APPLICATION_PROTOCOL_NEGOTIATION_EXT; + +typedef struct _SEC_APPLICATION_PROTOCOL_LIST { + SEC_APPLICATION_PROTOCOL_NEGOTIATION_EXT ProtoNegoExt; + unsigned short ProtocolListSize; + unsigned char ProtocolList[ANYSIZE_ARRAY]; +} SEC_APPLICATION_PROTOCOL_LIST, *PSEC_APPLICATION_PROTOCOL_LIST; + +typedef struct _SEC_APPLICATION_PROTOCOLS { + unsigned long ProtocolListsSize; + SEC_APPLICATION_PROTOCOL_LIST ProtocolLists[ANYSIZE_ARRAY]; +} SEC_APPLICATION_PROTOCOLS, *PSEC_APPLICATION_PROTOCOLS; + +typedef enum _SEC_APPLICATION_PROTOCOL_NEGOTIATION_STATUS { + SecApplicationProtocolNegotiationStatus_None, + SecApplicationProtocolNegotiationStatus_Success, + SecApplicationProtocolNegotiationStatus_SelectedClientOnly +} SEC_APPLICATION_PROTOCOL_NEGOTIATION_STATUS, *PSEC_APPLICATION_PROTOCOL_NEGOTIATION_STATUS; + +#define MAX_PROTOCOL_ID_SIZE 0xff + +typedef struct _SecPkgContext_ApplicationProtocol { + SEC_APPLICATION_PROTOCOL_NEGOTIATION_STATUS ProtoNegoStatus; + SEC_APPLICATION_PROTOCOL_NEGOTIATION_EXT ProtoNegoExt; + unsigned char ProtocolIdSize; + unsigned char ProtocolId[MAX_PROTOCOL_ID_SIZE]; +} SecPkgContext_ApplicationProtocol, *PSecPkgContext_ApplicationProtocol; +#endif // SECPKG_ATTR_APPLICATION_PROTOCOL + +#ifndef SECBUFFER_APPLICATION_PROTOCOLS +#define SECBUFFER_APPLICATION_PROTOCOLS 18 +#endif + // Proxy WCHAR * psz_proxy_server = L"proxy"; INT i_proxy_port = 80; @@ -28,6 +77,15 @@ struct TlsContext { BOOL validate_server_certificate; BOOL creds_initialized; BOOL context_initialized; + // ALPN protocol list to advertise, in the standard ALPN wire format (each + // name 1-byte length-prefixed), e.g. "\x02h2\x08http/1.1". alpn_wire_len == 0 + // means "do not advertise ALPN". + unsigned char alpn_wire[256]; + unsigned long alpn_wire_len; + // Negotiated application protocol name (e.g. "h2"); negotiated_alpn_len == 0 + // when the server selected none. + char negotiated_alpn[256]; + unsigned long negotiated_alpn_len; }; TlsContext new_tls_context() { @@ -38,10 +96,63 @@ TlsContext new_tls_context() { .validate_server_certificate = TRUE, .creds_initialized = FALSE, .context_initialized = FALSE, - .p_pemote_cert_context = NULL + .p_pemote_cert_context = NULL, + .alpn_wire_len = 0, + .negotiated_alpn_len = 0 }; }; +// vschannel_set_alpn configures the ALPN protocol list to advertise during the +// next handshake. `wire` is the standard ALPN wire format (each protocol name +// preceded by a 1-byte length), e.g. "\x02h2\x08http/1.1". Passing len == 0 +// disables ALPN advertisement. +void vschannel_set_alpn(TlsContext *tls_ctx, const char *wire, INT len) { + if (len < 0) { + len = 0; + } + if (len > (INT)sizeof(tls_ctx->alpn_wire)) { + len = (INT)sizeof(tls_ctx->alpn_wire); + } + if (len > 0) { + memcpy(tls_ctx->alpn_wire, wire, (size_t)len); + } + tls_ctx->alpn_wire_len = (unsigned long)len; +} + +// vschannel_get_alpn copies the protocol the server selected via ALPN (e.g. +// "h2") into `out` and returns its length, or 0 if none was negotiated. +INT vschannel_get_alpn(TlsContext *tls_ctx, char *out, INT out_cap) { + unsigned long n = tls_ctx->negotiated_alpn_len; + if (out_cap < 0) { + out_cap = 0; + } + if (n > (unsigned long)out_cap) { + n = (unsigned long)out_cap; + } + if (n > 0) { + memcpy(out, tls_ctx->negotiated_alpn, (size_t)n); + } + return (INT)n; +} + +// vschannel_capture_alpn queries the negotiated ALPN protocol from a completed +// handshake and stores it on the context for vschannel_get_alpn(). +static void vschannel_capture_alpn(TlsContext *tls_ctx) { + SecPkgContext_ApplicationProtocol appproto; + SECURITY_STATUS st; + + tls_ctx->negotiated_alpn_len = 0; + st = tls_ctx->sspi->QueryContextAttributes(&tls_ctx->h_context, + SECPKG_ATTR_APPLICATION_PROTOCOL, &appproto); + if (st == SEC_E_OK + && appproto.ProtoNegoStatus == SecApplicationProtocolNegotiationStatus_Success + && appproto.ProtocolIdSize > 0 + && appproto.ProtocolIdSize <= sizeof(tls_ctx->negotiated_alpn)) { + memcpy(tls_ctx->negotiated_alpn, appproto.ProtocolId, appproto.ProtocolIdSize); + tls_ctx->negotiated_alpn_len = appproto.ProtocolIdSize; + } +} + static void vschannel_clear_last_error(TlsContext *tls_ctx) { tls_ctx->last_error_code = 0; } @@ -136,6 +247,9 @@ INT request(TlsContext *tls_ctx, INT iport, LPWSTR host, CHAR *req, DWORD req_le } tls_ctx->context_initialized = TRUE; + // Record the ALPN protocol the server selected (if any). + vschannel_capture_alpn(tls_ctx); + if(tls_ctx->validate_server_certificate) { // Authenticate server's credentials. @@ -194,6 +308,43 @@ INT request(TlsContext *tls_ctx, INT iport, LPWSTR host, CHAR *req, DWORD req_le return resp_length; } +// vschannel_alpn_probe connects to host:iport, performs the TLS handshake while +// advertising whatever ALPN list was configured via vschannel_set_alpn(), +// captures the protocol the server selected into `out` (up to out_cap bytes), +// and disconnects without sending an application request. Returns the +// negotiated protocol length (0 = handshake succeeded but no protocol selected), +// or -1 on connect/handshake failure (see vschannel_last_error). Intended for +// tests and capability checks, since request() only speaks HTTP/1.1. +INT vschannel_alpn_probe(TlsContext *tls_ctx, INT iport, LPWSTR host, char *out, INT out_cap) { + SecBuffer ExtraData; + SECURITY_STATUS Status; + + protocol = SP_PROT_TLS1_2_CLIENT; + port_number = iport; + vschannel_clear_last_error(tls_ctx); + + if(connect_to_server(tls_ctx, host, port_number)) { + vschannel_cleanup(tls_ctx); + return -1; + } + + Status = perform_client_handshake(tls_ctx, host, &ExtraData); + if(Status) { + vschannel_set_last_error(tls_ctx, Status); + vschannel_cleanup(tls_ctx); + return -1; + } + tls_ctx->context_initialized = TRUE; + + vschannel_capture_alpn(tls_ctx); + + disconnect_from_server(tls_ctx); + tls_ctx->context_initialized = FALSE; + tls_ctx->socket = INVALID_SOCKET; + + return vschannel_get_alpn(tls_ctx, out, out_cap); +} + static SECURITY_STATUS create_credentials(TlsContext *tls_ctx) { TimeStamp tsExpiry; @@ -453,6 +604,38 @@ static SECURITY_STATUS perform_client_handshake(TlsContext *tls_ctx, WCHAR *host ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_STREAM; + // + // Optionally advertise ALPN protocols in the ClientHello. SChannel takes + // this as a SECBUFFER_APPLICATION_PROTOCOLS input buffer holding a + // SEC_APPLICATION_PROTOCOLS record. The backing store is a 4-byte-aligned + // unsigned long array so the struct cast is well aligned on every compiler. + // + SecBuffer InBuffers[1]; + SecBufferDesc InBuffer; + SecBufferDesc *pInput = NULL; + unsigned long alpn_store[80]; // 320 bytes; alpn_wire is at most 256 + if (tls_ctx->alpn_wire_len > 0) { + SEC_APPLICATION_PROTOCOLS *protos = (SEC_APPLICATION_PROTOCOLS *)alpn_store; + SEC_APPLICATION_PROTOCOL_LIST *list = &protos->ProtocolLists[0]; + unsigned long wlen = tls_ctx->alpn_wire_len; + + list->ProtoNegoExt = SecApplicationProtocolNegotiationExt_ALPN; + list->ProtocolListSize = (unsigned short)wlen; + memcpy(list->ProtocolList, tls_ctx->alpn_wire, (size_t)wlen); + protos->ProtocolListsSize = + (unsigned long)(FIELD_OFFSET(SEC_APPLICATION_PROTOCOL_LIST, ProtocolList) + wlen); + + InBuffers[0].pvBuffer = protos; + InBuffers[0].cbBuffer = + (unsigned long)(FIELD_OFFSET(SEC_APPLICATION_PROTOCOLS, ProtocolLists) + protos->ProtocolListsSize); + InBuffers[0].BufferType = SECBUFFER_APPLICATION_PROTOCOLS; + + InBuffer.cBuffers = 1; + InBuffer.pBuffers = InBuffers; + InBuffer.ulVersion = SECBUFFER_VERSION; + pInput = &InBuffer; + } + // // Initiate a ClientHello message and generate a token. // @@ -472,7 +655,7 @@ static SECURITY_STATUS perform_client_handshake(TlsContext *tls_ctx, WCHAR *host dwSSPIFlags, 0, SECURITY_NATIVE_DREP, - NULL, + pInput, 0, &tls_ctx->h_context, &OutBuffer, diff --git a/thirdparty/vschannel/vschannel.h b/thirdparty/vschannel/vschannel.h index b3b229f40..e17a6125a 100644 --- a/thirdparty/vschannel/vschannel.h +++ b/thirdparty/vschannel/vschannel.h @@ -26,6 +26,12 @@ typedef struct TlsContext TlsContext; TlsContext new_tls_context(); +// ALPN (RFC 7301) support. `wire` is the standard ALPN wire format: each +// protocol name preceded by a 1-byte length, e.g. "\x02h2\x08http/1.1". +void vschannel_set_alpn(TlsContext *tls_ctx, const char *wire, INT len); +INT vschannel_get_alpn(TlsContext *tls_ctx, char *out, INT out_cap); +INT vschannel_alpn_probe(TlsContext *tls_ctx, INT iport, LPWSTR host, char *out, INT out_cap); + static void vschannel_init(TlsContext *tls_ctx, BOOL validate_server_certificate); static void vschannel_cleanup(TlsContext *tls_ctx); diff --git a/vlib/net/http/vschannel_alpn_windows.c.v b/vlib/net/http/vschannel_alpn_windows.c.v new file mode 100644 index 000000000..82a22030f --- /dev/null +++ b/vlib/net/http/vschannel_alpn_windows.c.v @@ -0,0 +1,56 @@ +// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved. +// Use of this source code is governed by an MIT license +// that can be found in the LICENSE file. +module http + +// ALPN (RFC 7301) support for the Windows SChannel backend. The handshake-level +// plumbing lives in thirdparty/vschannel/vschannel.c; this exposes it to V. +// +// This adds the *capability* to advertise ALPN and read the negotiated protocol +// on SChannel. The one-shot vschannel request() path still speaks HTTP/1.1, so +// it is not wired into fetch() yet (advertising `h2` without an HTTP/2 driver +// would let a server pick a protocol we cannot speak). Negotiation is exercised +// via schannel_alpn_probe(). Wiring the request path to HTTP/2 is the follow-up. +// See vlang/v#27383. + +fn C.vschannel_set_alpn(tls_ctx &C.TlsContext, wire &char, len int) +fn C.vschannel_get_alpn(tls_ctx &C.TlsContext, out &char, out_cap int) int +fn C.vschannel_alpn_probe(tls_ctx &C.TlsContext, iport int, host &u16, out &char, out_cap int) int + +// alpn_wire encodes ALPN protocol names into the wire format SChannel expects: +// each name preceded by a single length byte, e.g. `['h2', 'http/1.1']` becomes +// "\x02h2\x08http/1.1". Empty names, or names longer than 255 bytes, are skipped. +fn alpn_wire(protocols []string) []u8 { + mut out := []u8{} + for p in protocols { + if p.len == 0 || p.len > 255 { + continue + } + out << u8(p.len) + out << p.bytes() + } + return out +} + +// schannel_alpn_probe performs a TLS handshake to `host`:`port` advertising +// `protocols` via ALPN and returns the protocol the server selected (e.g. 'h2'), +// or '' if none was negotiated. It sends no application data, so it works as a +// pure ALPN-negotiation check independent of the HTTP/1.1 request path. +// Windows/SChannel only. Pass `validate` = false to skip certificate validation +// (e.g. against a local test server with a self-signed cert). +fn schannel_alpn_probe(host string, port int, protocols []string, validate bool) string { + mut ctx := C.new_tls_context() + C.vschannel_use_tls12_client_protocol() + C.vschannel_init(&ctx, C.BOOL(if validate { 1 } else { 0 })) + wire := alpn_wire(protocols) + if wire.len > 0 { + C.vschannel_set_alpn(&ctx, &char(wire.data), wire.len) + } + mut buf := []u8{len: 256} + n := C.vschannel_alpn_probe(&ctx, port, host.to_wide(), &char(buf.data), buf.len) + C.vschannel_cleanup(&ctx) + if n <= 0 { + return '' + } + return buf[..n].bytestr() +} diff --git a/vlib/net/http/vschannel_alpn_windows_test.v b/vlib/net/http/vschannel_alpn_windows_test.v new file mode 100644 index 000000000..66a14d68c --- /dev/null +++ b/vlib/net/http/vschannel_alpn_windows_test.v @@ -0,0 +1,36 @@ +module http + +// ALPN negotiation on the Windows SChannel backend (vlang/v#27383). +// +// The wire-encoding test runs anywhere on Windows. The negotiation tests are +// network-dependent: run with `-d network`, e.g. +// v -d network test vlib/net/http/vschannel_alpn_windows_test.v + +fn test_alpn_wire_encoding() { + mut want := [u8(0x02)] + want << 'h2'.bytes() + want << 0x08 + want << 'http/1.1'.bytes() + assert alpn_wire(['h2', 'http/1.1']) == want + // Empty and over-long (>255) names are skipped. + assert alpn_wire([]string{}) == []u8{} + assert alpn_wire(['', 'h2']) == [u8(0x02), 0x68, 0x32] // 0x68='h', 0x32='2' +} + +fn test_schannel_alpn_negotiates_h2() { + $if !network ? { + return + } + // A public HTTP/2 server must select `h2` when offered. + selected := schannel_alpn_probe('www.google.com', 443, ['h2', 'http/1.1'], false) + assert selected == 'h2', 'expected h2, got "${selected}"' +} + +fn test_schannel_alpn_falls_back_to_http1() { + $if !network ? { + return + } + // Offer only HTTP/1.1: the server must not select h2. + selected := schannel_alpn_probe('www.google.com', 443, ['http/1.1'], false) + assert selected == 'http/1.1', 'expected http/1.1, got "${selected}"' +} -- 2.39.5