Skip to content

Commit 435181a

Browse files
committed
tls: coalesce buffered records into one read
SSL_read() returns at most one TLS record, and ClearOut() emitted a separate read for each one. Peers that use small records, such as nginx with its default 4 KiB ssl_buffer_size or servers doing dynamic record sizing, made every record pay for its own buffer allocation and call into JavaScript. While the next buffered record is application data, keep reading into the same 16 KiB buffer before emitting it, so reads never get larger than one full record already makes them. Batching stops before any other record type, so that e.g. a TLS 1.2 renegotiation is still processed after the data in front of it has been emitted. It also stops before records short enough to be empty, because SSL_read() skips empty records and carries on with the next one. If SSL_read() fails in the middle of a batch, the error is captured first and reported after the data. test-tls-max-send-fragment relied on 'data' chunks matching records, so it now checks the record sizes on the wire. Signed-off-by: Robert Nagy <ronagy@icloud.com> Assisted-by: claude:opus-5.5
1 parent c883ccf commit 435181a

5 files changed

Lines changed: 293 additions & 29 deletions

File tree

‎benchmark/tls/throughput-s2c.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const bench = common.createBenchmark(main, {
66
sendchunklen: [256, 32 * 1024, 128 * 1024, 16 * 1024 * 1024],
77
recvbuflen: [0, 64 * 1024, 1024 * 1024],
88
recvbufgenfn: ['true', 'false'],
9+
recordlen: [1024, 4096, 16384],
910
});
1011

1112
const fixtures = require('../../test/common/fixtures');
@@ -14,7 +15,8 @@ let recvbuf;
1415
let received = 0;
1516
const tls = require('tls');
1617

17-
function main({ dur, type, sendchunklen, recvbuflen, recvbufgenfn }) {
18+
function main({ dur, type, sendchunklen, recvbuflen, recvbufgenfn,
19+
recordlen }) {
1820
if (isFinite(recvbuflen) && recvbuflen > 0)
1921
recvbuf = Buffer.alloc(recvbuflen);
2022

@@ -74,6 +76,7 @@ function main({ dur, type, sendchunklen, recvbuflen, recvbufgenfn }) {
7476
}
7577

7678
const server = tls.createServer(options, (socket) => {
79+
socket.setMaxSendFragment(recordlen);
7780
socket.on('data', (buf) => {
7881
socket.on('drain', write);
7982
write();

‎src/crypto/crypto_tls.cc‎

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -880,20 +880,14 @@ void TLSWrap::ClearOut() {
880880
MarkPopErrorOnReturn mark_pop_error_on_return;
881881

882882
char out[kClearOutChunkSize];
883+
int length = 0;
883884
int read;
884-
for (;;) {
885-
{
886-
SSLLibraryCallScope ssl_library_call_scope(this);
887-
read = SSL_read(ssl_.get(), out, sizeof(out));
888-
}
889-
Debug(this, "Read %d bytes of cleartext output", read);
890-
891-
if (read <= 0)
892-
break;
893885

886+
// Emits out[0, length). Returns false if JS destroyed the SSL context.
887+
auto emit = [&]() {
894888
char* current = out;
895-
while (read > 0) {
896-
int avail = read;
889+
while (length > 0) {
890+
int avail = length;
897891

898892
uv_buf_t buf = EmitAlloc(avail);
899893
if (static_cast<int>(buf.len) < avail)
@@ -906,26 +900,57 @@ void TLSWrap::ClearOut() {
906900
// check that ssl_ != nullptr afterwards.
907901
if (ssl_ == nullptr) {
908902
Debug(this, "Returning from read loop, ssl_ == nullptr");
909-
return;
903+
return false;
910904
}
911905

912-
read -= avail;
906+
length -= avail;
913907
current += avail;
914908
}
909+
return true;
910+
};
911+
912+
for (;;) {
913+
{
914+
SSLLibraryCallScope ssl_library_call_scope(this);
915+
read = SSL_read(ssl_.get(), out + length, kClearOutChunkSize - length);
916+
}
917+
Debug(this, "Read %d bytes of cleartext output", read);
918+
919+
if (read <= 0) break;
920+
length += read;
921+
922+
// SSL_read() returns at most one record. If the next record is already
923+
// buffered, decrypt it into the same chunk instead of emitting one read
924+
// per record. This is limited to application data records that are too
925+
// long to be empty: other record types can trigger callbacks, e.g. for
926+
// renegotiation, that have to run after the preceding data has been
927+
// emitted, and SSL_read() skips over empty records to whatever follows.
928+
size_t pending;
929+
const unsigned char* next = reinterpret_cast<const unsigned char*>(
930+
NodeBIO::FromBIO(enc_in_)->Peek(&pending));
931+
if (length < kClearOutChunkSize && pending >= SSL3_RT_HEADER_LENGTH &&
932+
next[0] == SSL3_RT_APPLICATION_DATA &&
933+
(next[3] << 8 | next[4]) > SSL3_RT_MAX_ENCRYPTED_OVERHEAD) {
934+
continue;
935+
}
936+
937+
if (!emit()) return;
915938
}
916939

917940
// We need to check whether an error occurred or the connection was
918941
// shutdown cleanly (SSL_ERROR_ZERO_RETURN) even when read == 0.
919942
// See node#1642 and SSL_read(3SSL) for details. SSL_get_error must be
920943
// called immediately after SSL_read, without calling into JS, which may
921944
// change OpenSSL's error queue, modify ssl_, or even destroy ssl_
922-
// altogether.
945+
// altogether. Data that was read before the failure is emitted once the
946+
// error has been captured, before it is reported.
923947
if (read <= 0) {
924948
HandleScope handle_scope(env()->isolate());
925949
Local<Value> error;
926950
int err = SSL_get_error(ssl_.get(), read);
927951
switch (err) {
928952
case SSL_ERROR_ZERO_RETURN:
953+
if (!emit()) return;
929954
if (!flags_.eof) {
930955
flags_.eof = true;
931956
EmitRead(UV_EOF);
@@ -975,9 +1000,12 @@ void TLSWrap::ClearOut() {
9751000
break;
9761001

9771002
default:
1003+
emit();
9781004
return;
9791005
}
9801006

1007+
if (!emit()) return;
1008+
9811009
Debug(this, "Got SSL error (%d), calling onerror", err);
9821010
// When TLS Alert are stored in wbio,
9831011
// it should be flushed to socket before destroyed.
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
'use strict';
2+
// TLS records that are already buffered are emitted as one chunk rather than
3+
// one chunk per record. The end of the stream, clean or not, must still be
4+
// reported after the data that precedes it.
5+
6+
const common = require('../common');
7+
if (!common.hasCrypto)
8+
common.skip('missing crypto');
9+
10+
const assert = require('assert');
11+
const net = require('net');
12+
const tls = require('tls');
13+
const fixtures = require('../common/fixtures');
14+
15+
const RECORD_SIZE = 512;
16+
const RECORDS = 256;
17+
const payload = Buffer.alloc(RECORD_SIZE * RECORDS);
18+
for (let i = 0; i < RECORDS; i++)
19+
payload.fill(i, i * RECORD_SIZE, (i + 1) * RECORD_SIZE);
20+
21+
const serverOptions = {
22+
key: fixtures.readKey('agent1-key.pem'),
23+
cert: fixtures.readKey('agent1-cert.pem'),
24+
};
25+
const clientOptions = {
26+
ca: fixtures.readKey('ca1-cert.pem'),
27+
servername: 'agent1',
28+
};
29+
30+
function sendPayload(socket) {
31+
assert(socket.setMaxSendFragment(RECORD_SIZE));
32+
socket.end(payload);
33+
}
34+
35+
// Small records are coalesced, and 'end' follows all of the data.
36+
{
37+
const server = tls.createServer(serverOptions, common.mustCall(sendPayload));
38+
server.listen(0, common.mustCall(() => {
39+
const chunks = [];
40+
const client = tls.connect({ ...clientOptions, port: server.address().port });
41+
client.on('data', (chunk) => chunks.push(chunk));
42+
client.on('end', common.mustCall(() => {
43+
assert.deepStrictEqual(Buffer.concat(chunks), payload);
44+
assert(chunks.length < RECORDS, `${chunks.length} chunks`);
45+
assert(chunks.every((chunk) => chunk.length <= 16 * 1024));
46+
server.close();
47+
}));
48+
}));
49+
}
50+
51+
// An onread buffer smaller than a record still receives every byte.
52+
{
53+
const server = tls.createServer(serverOptions, common.mustCall(sendPayload));
54+
server.listen(0, common.mustCall(() => {
55+
const chunks = [];
56+
const client = tls.connect({
57+
...clientOptions,
58+
port: server.address().port,
59+
onread: {
60+
buffer: Buffer.alloc(100),
61+
callback(nread, buf) {
62+
chunks.push(Buffer.from(buf.subarray(0, nread)));
63+
},
64+
},
65+
});
66+
client.on('end', common.mustCall(() => {
67+
assert.deepStrictEqual(Buffer.concat(chunks), payload);
68+
server.close();
69+
}));
70+
}));
71+
}
72+
73+
// A bad record that arrives together with data is reported after the data.
74+
{
75+
const server = tls.createServer(serverOptions, common.mustCall((socket) => {
76+
socket.once('data', common.mustCall(() => sendPayload(socket)));
77+
}));
78+
server.listen(0, common.mustCall(() => {
79+
// Passes the handshake through, then holds the response until the server
80+
// ends and forwards it in one write, with the last record (close_notify)
81+
// corrupted.
82+
let holding = false;
83+
const held = [];
84+
const proxy = net.createServer(common.mustCall((socket) => {
85+
const upstream = net.connect(server.address().port);
86+
socket.pipe(upstream);
87+
socket.on('error', () => {});
88+
upstream.on('data', (chunk) => {
89+
if (holding)
90+
held.push(chunk);
91+
else
92+
socket.write(chunk);
93+
});
94+
upstream.on('end', common.mustCall(() => {
95+
const data = Buffer.concat(held);
96+
data[data.length - 1] ^= 1;
97+
socket.end(data);
98+
}));
99+
}));
100+
proxy.listen(0, common.mustCall(() => {
101+
const chunks = [];
102+
const client = tls.connect({
103+
...clientOptions,
104+
port: proxy.address().port,
105+
}, common.mustCall(() => {
106+
holding = true;
107+
client.write('go');
108+
}));
109+
client.on('data', (chunk) => chunks.push(chunk));
110+
client.on('error', common.mustCall((err) => {
111+
assert.strictEqual(err.code,
112+
'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC');
113+
assert.deepStrictEqual(Buffer.concat(chunks), payload);
114+
proxy.close();
115+
server.close();
116+
}));
117+
}));
118+
}));
119+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use strict';
2+
const common = require('../common');
3+
if (!common.hasCrypto)
4+
common.skip('missing crypto');
5+
const { isBoringSSL } = require('../common/crypto');
6+
if (isBoringSSL)
7+
common.skip('BoringSSL does not support renegotiation');
8+
9+
const assert = require('assert');
10+
const net = require('net');
11+
const tls = require('tls');
12+
const fixtures = require('../common/fixtures');
13+
14+
// Deliver application data and a TLS 1.2 ClientHello in one transport write.
15+
// The data handler must run before renegotiation starts, so it can disable it.
16+
const payload = Buffer.from('disable renegotiation');
17+
let holding = false;
18+
let client;
19+
20+
const server = tls.createServer({
21+
key: fixtures.readKey('agent1-key.pem'),
22+
cert: fixtures.readKey('agent1-cert.pem'),
23+
maxVersion: 'TLSv1.2',
24+
}, common.mustCall((socket) => {
25+
socket.on('data', common.mustCall((data) => {
26+
assert.deepStrictEqual(data, payload);
27+
socket.disableRenegotiation();
28+
}));
29+
socket.on('secure', common.mustNotCall('renegotiation completed'));
30+
socket.on('error', common.mustCall((err) => {
31+
assert.strictEqual(err.code, 'ERR_TLS_RENEGOTIATION_DISABLED');
32+
client.destroy();
33+
socket.destroy();
34+
server.close();
35+
proxy.close();
36+
}));
37+
}));
38+
39+
const proxy = net.createServer(common.mustCall((socket) => {
40+
const upstream = net.connect(server.address().port);
41+
upstream.pipe(socket);
42+
socket.on('end', () => upstream.end());
43+
socket.on('close', () => upstream.destroy());
44+
const held = [];
45+
let pending = Buffer.alloc(0);
46+
socket.on('data', (data) => {
47+
if (!holding) {
48+
upstream.write(data);
49+
return;
50+
}
51+
held.push(data);
52+
pending = Buffer.concat([pending, data]);
53+
while (pending.length >= 5) {
54+
const length = pending.readUInt16BE(3);
55+
if (pending.length < 5 + length)
56+
break;
57+
if (pending[0] === 22) { // handshake
58+
holding = false;
59+
upstream.write(Buffer.concat(held));
60+
return;
61+
}
62+
pending = pending.subarray(5 + length);
63+
}
64+
});
65+
}));
66+
67+
server.listen(0, common.mustCall(() => {
68+
proxy.listen(0, common.mustCall(() => {
69+
client = tls.connect({
70+
port: proxy.address().port,
71+
ca: fixtures.readKey('ca1-cert.pem'),
72+
servername: 'agent1',
73+
maxVersion: 'TLSv1.2',
74+
}, common.mustCall(() => {
75+
holding = true;
76+
client.write(payload, common.mustCall(() => {
77+
assert(client.renegotiate({},
78+
common.mustNotCall('renegotiation completed')));
79+
}));
80+
}));
81+
}));
82+
}));

0 commit comments

Comments
 (0)