Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tls: add ALPNCallback server option for dynamic ALPN negotiation #45190

Merged
merged 5 commits into from
Jun 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -2746,6 +2746,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 @@ -2049,6 +2049,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: v19.0.0
pr-url: https://github.com/nodejs/node/pull/44031
description: If `ALPNProtocols` is set, incoming connections that send an
Expand Down Expand Up @@ -2079,6 +2082,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
pimterry marked this conversation as resolved.
Show resolved Hide resolved
`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 @@ -755,6 +798,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');
pimterry marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -1133,6 +1186,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 @@ -1232,6 +1286,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 @@ -1628,6 +1628,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 @@ -1224,6 +1262,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 @@ -2034,6 +2081,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 @@ -2099,6 +2147,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 @@ -172,6 +172,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 @@ -285,6 +286,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 @@ -50,6 +50,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 @@ -41,9 +41,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 @@ -53,6 +52,15 @@ function runTest(clientsOptions, serverOptions, cb) {
cb(results);
});
}
}

const client = tls.connect(opt, function() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrap the callback with common.mustCall(...)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the other callbacks in this file now in the latest commit, but I think this one isn't actually a mustCall. This is a function (runTest) is called by quite a few tests in here with different options each time, and some of them expect this callback to run while others test failures where it should never run.

All of them check the results values that get set within the callbacks here though - I think that's probably sufficient?

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

Expand Down Expand Up @@ -200,12 +208,73 @@ function TestFatalAlert() {
.on('close', common.mustCall(() => {
assert.match(stderr, /SSL alert number 120/);
server.close();
TestALPNCallback();
}));
} else {
server.close();
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();