Skip to content

Commit

Permalink
tls: add ALPNCallback server option for dynamic ALPN negotiation
Browse files Browse the repository at this point in the history
PR-URL: #45190
Reviewed-By: James M Snell <[email protected]>
Reviewed-By: Benjamin Gruenbaum <[email protected]>
Reviewed-By: Debadree Chatterjee <[email protected]>
  • Loading branch information
pimterry authored and targos committed Oct 28, 2023
1 parent ae49f31 commit b5d16cd
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 3 deletions.
14 changes: 14 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2708,6 +2708,20 @@ This error represents a failed test. Additional information about the failure
is available via the `cause` property. The `failureType` property specifies
what the test was doing when the failure occurred.

<a id="ERR_TLS_ALPN_CALLBACK_INVALID_RESULT"></a>

### `ERR_TLS_ALPN_CALLBACK_INVALID_RESULT`

This error is thrown when an `ALPNCallback` returns a value that is not in the
list of ALPN protocols offered by the client.

<a id="ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS"></a>

### `ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS`

This error is thrown when creating a `TLSServer` if the TLS options include
both `ALPNProtocols` and `ALPNCallback`. These options are mutually exclusive.

<a id="ERR_TLS_CERT_ALTNAME_FORMAT"></a>

### `ERR_TLS_CERT_ALTNAME_FORMAT`
Expand Down
14 changes: 14 additions & 0 deletions doc/api/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -2045,6 +2045,9 @@ where `secureSocket` has the same API as `pair.cleartext`.
<!-- YAML
added: v0.3.2
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/45190
description: The `options` parameter can now include `ALPNCallback`.
- version: v12.3.0
pr-url: https://github.com/nodejs/node/pull/27665
description: The `options` parameter now supports `net.createServer()`
Expand All @@ -2070,6 +2073,17 @@ changes:
e.g. `0x05hello0x05world`, where the first byte is the length of the next
protocol name. Passing an array is usually much simpler, e.g.
`['hello', 'world']`. (Protocols should be ordered by their priority.)
* `ALPNCallback`: {Function} If set, this will be called when a
client opens a connection using the ALPN extension. One argument will
be passed to the callback: an object containing `servername` and
`protocols` fields, respectively containing the server name from
the SNI extension (if any) and an array of ALPN protocol name strings. The
callback must return either one of the strings listed in
`protocols`, which will be returned to the client as the selected
ALPN protocol, or `undefined`, to reject the connection with a fatal alert.
If a string is returned that does not match one of the client's ALPN
protocols, an error will be thrown. This option cannot be used with the
`ALPNProtocols` option, and setting both options will throw an error.
* `clientCertEngine` {string} Name of an OpenSSL engine which can provide the
client certificate.
* `enableTrace` {boolean} If `true`, [`tls.TLSSocket.enableTrace()`][] will be
Expand Down
59 changes: 59 additions & 0 deletions lib/_tls_wrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const {
ERR_INVALID_ARG_VALUE,
ERR_MULTIPLE_CALLBACK,
ERR_SOCKET_CLOSED,
ERR_TLS_ALPN_CALLBACK_INVALID_RESULT,
ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS,
ERR_TLS_DH_PARAM_SIZE,
ERR_TLS_HANDSHAKE_TIMEOUT,
ERR_TLS_INVALID_CONTEXT,
Expand Down Expand Up @@ -108,6 +110,7 @@ const kErrorEmitted = Symbol('error-emitted');
const kHandshakeTimeout = Symbol('handshake-timeout');
const kRes = Symbol('res');
const kSNICallback = Symbol('snicallback');
const kALPNCallback = Symbol('alpncallback');
const kEnableTrace = Symbol('enableTrace');
const kPskCallback = Symbol('pskcallback');
const kPskIdentityHint = Symbol('pskidentityhint');
Expand Down Expand Up @@ -234,6 +237,45 @@ function loadSNI(info) {
}


function callALPNCallback(protocolsBuffer) {
const handle = this;
const socket = handle[owner_symbol];

const servername = handle.getServername();

// Collect all the protocols from the given buffer:
const protocols = [];
let offset = 0;
while (offset < protocolsBuffer.length) {
const protocolLen = protocolsBuffer[offset];
offset += 1;

const protocol = protocolsBuffer.slice(offset, offset + protocolLen);
offset += protocolLen;

protocols.push(protocol.toString('ascii'));
}

const selectedProtocol = socket[kALPNCallback]({
servername,
protocols,
});

// Undefined -> all proposed protocols rejected
if (selectedProtocol === undefined) return undefined;

const protocolIndex = protocols.indexOf(selectedProtocol);
if (protocolIndex === -1) {
throw new ERR_TLS_ALPN_CALLBACK_INVALID_RESULT(selectedProtocol, protocols);
}
let protocolOffset = 0;
for (let i = 0; i < protocolIndex; i++) {
protocolOffset += 1 + protocols[i].length;
}

return protocolOffset;
}

function requestOCSP(socket, info) {
if (!info.OCSPRequest || !socket.server)
return requestOCSPDone(socket);
Expand Down Expand Up @@ -493,6 +535,7 @@ function TLSSocket(socket, opts) {
this._controlReleased = false;
this.secureConnecting = true;
this._SNICallback = null;
this[kALPNCallback] = null;
this.servername = null;
this.alpnProtocol = null;
this.authorized = false;
Expand Down Expand Up @@ -787,6 +830,16 @@ TLSSocket.prototype._init = function(socket, wrap) {
ssl.lastHandshakeTime = 0;
ssl.handshakes = 0;

if (options.ALPNCallback) {
if (typeof options.ALPNCallback !== 'function') {
throw new ERR_INVALID_ARG_TYPE('options.ALPNCallback', 'Function', options.ALPNCallback);
}
assert(typeof options.ALPNCallback === 'function');
this[kALPNCallback] = options.ALPNCallback;
ssl.ALPNCallback = callALPNCallback;
ssl.enableALPNCb();
}

if (this.server) {
if (this.server.listenerCount('resumeSession') > 0 ||
this.server.listenerCount('newSession') > 0) {
Expand Down Expand Up @@ -1165,6 +1218,7 @@ function tlsConnectionListener(rawSocket) {
rejectUnauthorized: this.rejectUnauthorized,
handshakeTimeout: this[kHandshakeTimeout],
ALPNProtocols: this.ALPNProtocols,
ALPNCallback: this.ALPNCallback,
SNICallback: this[kSNICallback] || SNICallback,
enableTrace: this[kEnableTrace],
pauseOnConnect: this.pauseOnConnect,
Expand Down Expand Up @@ -1264,6 +1318,11 @@ function Server(options, listener) {
this.requestCert = options.requestCert === true;
this.rejectUnauthorized = options.rejectUnauthorized !== false;

this.ALPNCallback = options.ALPNCallback;
if (this.ALPNCallback && options.ALPNProtocols) {
throw new ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS();
}

if (options.sessionTimeout)
this.sessionTimeout = options.sessionTimeout;

Expand Down
10 changes: 10 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1622,6 +1622,16 @@ E('ERR_TEST_FAILURE', function(error, failureType) {
this.cause = error;
return msg;
}, Error);
E('ERR_TLS_ALPN_CALLBACK_INVALID_RESULT', (value, protocols) => {
return `ALPN callback returned a value (${
value
}) that did not match any of the client's offered protocols (${
protocols.join(', ')
})`;
}, TypeError);
E('ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS',
'The ALPNCallback and ALPNProtocols TLS options are mutually exclusive',
TypeError);
E('ERR_TLS_CERT_ALTNAME_FORMAT', 'Invalid subject alternative name string',
SyntaxError);
E('ERR_TLS_CERT_ALTNAME_INVALID', function(reason, host, cert) {
Expand Down
49 changes: 49 additions & 0 deletions src/crypto/crypto_tls.cc
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,44 @@ int SelectALPNCallback(
unsigned int inlen,
void* arg) {
TLSWrap* w = static_cast<TLSWrap*>(arg);
if (w->alpn_callback_enabled_) {
Environment* env = w->env();
HandleScope handle_scope(env->isolate());

Local<Value> callback_arg =
Buffer::Copy(env, reinterpret_cast<const char*>(in), inlen)
.ToLocalChecked();

MaybeLocal<Value> maybe_callback_result =
w->MakeCallback(env->alpn_callback_string(), 1, &callback_arg);

if (UNLIKELY(maybe_callback_result.IsEmpty())) {
// Implies the callback didn't return, because some exception was thrown
// during processing, e.g. if callback returned an invalid ALPN value.
return SSL_TLSEXT_ERR_ALERT_FATAL;
}

Local<Value> callback_result = maybe_callback_result.ToLocalChecked();

if (callback_result->IsUndefined()) {
// If you set an ALPN callback, but you return undefined for an ALPN
// request, you're rejecting all proposed ALPN protocols, and so we send
// a fatal alert:
return SSL_TLSEXT_ERR_ALERT_FATAL;
}

CHECK(callback_result->IsNumber());
unsigned int result_int = callback_result.As<v8::Number>()->Value();

// The callback returns an offset into the given buffer, for the selected
// protocol that should be returned. We then set outlen & out to point
// to the selected input length & value directly:
*outlen = *(in + result_int);
*out = (in + result_int + 1);

return SSL_TLSEXT_ERR_OK;
}

const std::vector<unsigned char>& alpn_protos = w->alpn_protos_;

if (alpn_protos.empty()) return SSL_TLSEXT_ERR_NOACK;
Expand Down Expand Up @@ -1249,6 +1287,15 @@ void TLSWrap::OnClientHelloParseEnd(void* arg) {
c->Cycle();
}

void TLSWrap::EnableALPNCb(const FunctionCallbackInfo<Value>& args) {
TLSWrap* wrap;
ASSIGN_OR_RETURN_UNWRAP(&wrap, args.Holder());
wrap->alpn_callback_enabled_ = true;

SSL* ssl = wrap->ssl_.get();
SSL_CTX_set_alpn_select_cb(SSL_get_SSL_CTX(ssl), SelectALPNCallback, wrap);
}

void TLSWrap::GetServername(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);

Expand Down Expand Up @@ -2069,6 +2116,7 @@ void TLSWrap::Initialize(
SetProtoMethod(isolate, t, "certCbDone", CertCbDone);
SetProtoMethod(isolate, t, "destroySSL", DestroySSL);
SetProtoMethod(isolate, t, "enableCertCb", EnableCertCb);
SetProtoMethod(isolate, t, "enableALPNCb", EnableALPNCb);
SetProtoMethod(isolate, t, "endParser", EndParser);
SetProtoMethod(isolate, t, "enableKeylogCallback", EnableKeylogCallback);
SetProtoMethod(isolate, t, "enableSessionCallbacks", EnableSessionCallbacks);
Expand Down Expand Up @@ -2138,6 +2186,7 @@ void TLSWrap::RegisterExternalReferences(ExternalReferenceRegistry* registry) {
registry->Register(CertCbDone);
registry->Register(DestroySSL);
registry->Register(EnableCertCb);
registry->Register(EnableALPNCb);
registry->Register(EndParser);
registry->Register(EnableKeylogCallback);
registry->Register(EnableSessionCallbacks);
Expand Down
2 changes: 2 additions & 0 deletions src/crypto/crypto_tls.h
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ class TLSWrap : public AsyncWrap,
static void CertCbDone(const v8::FunctionCallbackInfo<v8::Value>& args);
static void DestroySSL(const v8::FunctionCallbackInfo<v8::Value>& args);
static void EnableCertCb(const v8::FunctionCallbackInfo<v8::Value>& args);
static void EnableALPNCb(const v8::FunctionCallbackInfo<v8::Value>& args);
static void EnableKeylogCallback(
const v8::FunctionCallbackInfo<v8::Value>& args);
static void EnableSessionCallbacks(
Expand Down Expand Up @@ -292,6 +293,7 @@ class TLSWrap : public AsyncWrap,

public:
std::vector<unsigned char> alpn_protos_; // Accessed by SelectALPNCallback.
bool alpn_callback_enabled_ = false; // Accessed by SelectALPNCallback.
};

} // namespace crypto
Expand Down
1 change: 1 addition & 0 deletions src/env_properties.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
V(ack_string, "ack") \
V(address_string, "address") \
V(aliases_string, "aliases") \
V(alpn_callback_string, "ALPNCallback") \
V(args_string, "args") \
V(asn1curve_string, "asn1Curve") \
V(async_ids_stack_string, "async_ids_stack") \
Expand Down
75 changes: 72 additions & 3 deletions test/parallel/test-tls-alpn-server-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@ function runTest(clientsOptions, serverOptions, cb) {
opt.rejectUnauthorized = false;

results[clientIndex] = {};
const client = tls.connect(opt, function() {
results[clientIndex].client = { ALPN: client.alpnProtocol };
client.end();

function startNextClient() {
if (options.length) {
clientIndex++;
connectClient(options);
Expand All @@ -52,6 +51,15 @@ function runTest(clientsOptions, serverOptions, cb) {
cb(results);
});
}
}

const client = tls.connect(opt, function() {
results[clientIndex].client = { ALPN: client.alpnProtocol };
client.end();
startNextClient();
}).on('error', function(err) {
results[clientIndex].client = { error: err };
startNextClient();
});
}

Expand Down Expand Up @@ -161,6 +169,67 @@ function Test4() {
{ server: { ALPN: false },
client: { ALPN: false } });
});

TestALPNCallback();
}

function TestALPNCallback() {
// Server always selects the client's 2nd preference:
const serverOptions = {
ALPNCallback: common.mustCall(({ protocols }) => {
return protocols[1];
}, 2)
};

const clientsOptions = [{
ALPNProtocols: ['a', 'b', 'c'],
}, {
ALPNProtocols: ['a'],
}];

runTest(clientsOptions, serverOptions, function(results) {
// Callback picks 2nd preference => picks 'b'
checkResults(results[0],
{ server: { ALPN: 'b' },
client: { ALPN: 'b' } });

// Callback picks 2nd preference => undefined => ALPN rejected:
assert.strictEqual(results[1].server, undefined);
assert.strictEqual(results[1].client.error.code, 'ECONNRESET');

TestBadALPNCallback();
});
}

function TestBadALPNCallback() {
// Server always returns a fixed invalid value:
const serverOptions = {
ALPNCallback: common.mustCall(() => 'http/5')
};

const clientsOptions = [{
ALPNProtocols: ['http/1', 'h2'],
}];

process.once('uncaughtException', common.mustCall((error) => {
assert.strictEqual(error.code, 'ERR_TLS_ALPN_CALLBACK_INVALID_RESULT');
}));

runTest(clientsOptions, serverOptions, function(results) {
// Callback returns 'http/5' => doesn't match client ALPN => error & reset
assert.strictEqual(results[0].server, undefined);
assert.strictEqual(results[0].client.error.code, 'ECONNRESET');

TestALPNOptionsCallback();
});
}

function TestALPNOptionsCallback() {
// Server sets two incompatible ALPN options:
assert.throws(() => tls.createServer({
ALPNCallback: () => 'a',
ALPNProtocols: ['b', 'c']
}), (error) => error.code === 'ERR_TLS_ALPN_CALLBACK_WITH_PROTOCOLS');
}

Test1();

0 comments on commit b5d16cd

Please sign in to comment.