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
  • Loading branch information
pimterry committed Oct 27, 2022
1 parent c127e4e commit f0293c2
Show file tree
Hide file tree
Showing 8 changed files with 207 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 @@ -2698,6 +2698,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 @@ -2012,6 +2012,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 @@ -2042,6 +2045,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(params)`: {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
`clientALPNProtocols` 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
`clientALPNProtocols`, 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
56 changes: 56 additions & 0 deletions lib/_tls_wrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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 @@ -233,6 +235,46 @@ 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._ALPNCallback({
serverName,
clientALPNProtocols: 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 @@ -492,6 +534,7 @@ function TLSSocket(socket, opts) {
this._controlReleased = false;
this.secureConnecting = true;
this._SNICallback = null;
this._ALPNCallback = null;
this.servername = null;
this.alpnProtocol = null;
this.authorized = false;
Expand Down Expand Up @@ -717,6 +760,13 @@ TLSSocket.prototype._init = function(socket, wrap) {
ssl.lastHandshakeTime = 0;
ssl.handshakes = 0;

if (options.ALPNCallback) {
assert(typeof options.ALPNCallback === 'function');
this._ALPNCallback = 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 @@ -1097,6 +1147,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 @@ -1196,6 +1247,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 @@ -1612,6 +1612,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
48 changes: 48 additions & 0 deletions src/crypto/crypto_tls.cc
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,43 @@ int SelectALPNCallback(
unsigned int inlen,
void* arg) {
TLSWrap* w = static_cast<TLSWrap*>(arg);
if (w->alpn_callback_enabled_) {
Environment* env = w->env();

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 @@ -1233,6 +1270,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 @@ -2044,6 +2090,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 @@ -2109,6 +2156,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 @@ -174,6 +174,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 @@ -287,6 +288,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
65 changes: 62 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() {
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,63 @@ 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: ({ clientALPNProtocols }) => {
return clientALPNProtocols[1];
}
};

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: () => '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');
});
}

Test1();

0 comments on commit f0293c2

Please sign in to comment.