Skip to content

Commit 02f0732

Browse files
committed
quic: add support for custom app ticket data config & 0RTT validation
Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent c612f35 commit 02f0732

8 files changed

Lines changed: 261 additions & 19 deletions

File tree

doc/api/quic.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,9 @@ Two pieces of state from a prior connection make this possible:
379379
If the server accepts the session ticket, any data sent before the handshake
380380
completes is 0-RTT early data. On the server side, `stream.early` is `true`
381381
for streams carrying early data. The server can reject the 0-RTT attempt
382-
(for example, if its configuration has changed since the ticket was issued).
382+
(for example, if its configuration has changed since the ticket was issued);
383+
[`sessionOptions.appTicketData`][] lets a server gate this explicitly, rejecting
384+
early data whose ticket does not exactly match the server's current value.
383385
When this happens, all streams opened during the 0-RTT phase are destroyed and
384386
the client's [`session.onearlyrejected`][] callback fires. The connection
385387
falls back to a normal 1-RTT handshake and the application can reopen streams.
@@ -2880,6 +2882,26 @@ await listen((session) => { /* ... */ }, {
28802882
});
28812883
```
28822884

2885+
#### `sessionOptions.appTicketData` (server only)
2886+
2887+
<!-- YAML
2888+
added: REPLACEME
2889+
-->
2890+
2891+
* Type: {ArrayBufferView}
2892+
2893+
Opaque application data to embed in the session tickets this server issues.
2894+
On resumption, the data carried by the presented ticket is compared
2895+
against the value currently configured here; if it does not match exactly,
2896+
the ticket's 0-RTT early data is rejected and the connection falls back to a
2897+
full 1-RTT handshake. Use it to bind 0-RTT acceptance to server-side state
2898+
that must agree between the original and resumed connection - rotating the
2899+
value invalidates the 0-RTT of all previously issued tickets.
2900+
2901+
This applies to the default QUIC application. HTTP/3 sessions carry their own
2902+
session-ticket data, so `appTicketData` is ignored when the negotiated ALPN
2903+
is `h3`.
2904+
28832905
#### `sessionOptions.ca` (client only)
28842906

28852907
<!-- YAML
@@ -4493,6 +4515,7 @@ throughput issues caused by flow control.
44934515
[`session.onsessionticket`]: #sessiononsessionticket
44944516
[`session.onstream`]: #sessiononstream
44954517
[`session.sendDatagram()`]: #sessionsenddatagramdatagram-encoding
4518+
[`sessionOptions.appTicketData`]: #sessionoptionsappticketdata-server-only
44964519
[`sessionOptions.cc`]: #sessionoptionscc
44974520
[`sessionOptions.ciphers`]: #sessionoptionsciphers
44984521
[`sessionOptions.datagramDropPolicy`]: #sessionoptionsdatagramdroppolicy

lib/internal/quic/quic.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,10 @@ const endpointRegistry = new SafeSet();
425425
* prior session, used to resume that session (client only).
426426
* @property {ArrayBufferView} [token] An opaque address validation token
427427
* previously received from the server via `onnewtoken` (client only).
428+
* @property {ArrayBufferView} [appTicketData] Opaque application data to embed
429+
* in issued session tickets (server only). This is written into new tickets,
430+
* and validated against received 0-RTT early data tickets to confirm if they
431+
* can be accepted (anything but an exact match is rejected).
428432
* @property {bigint|number} [handshakeTimeout] The handshake timeout
429433
* @property {bigint|number} [initialRtt] The initial round-trip time estimate in milliseconds.
430434
* Used for PTO computation and initial pacing before the first RTT sample. Default uses
@@ -5165,6 +5169,7 @@ function processSessionOptions(options, config = kEmptyObject) {
51655169
qlog = false,
51665170
sessionTicket,
51675171
token,
5172+
appTicketData,
51685173
maxPayloadSize,
51695174
unacknowledgedPacketThreshold = 0,
51705175
handshakeTimeout,
@@ -5219,6 +5224,22 @@ function processSessionOptions(options, config = kEmptyObject) {
52195224
}
52205225
}
52215226

5227+
if (appTicketData !== undefined) {
5228+
if (!forServer) {
5229+
throw new ERR_INVALID_ARG_VALUE(
5230+
'options.appTicketData', appTicketData,
5231+
'is only supported for server sessions');
5232+
}
5233+
if (!isArrayBufferView(appTicketData)) {
5234+
throw new ERR_INVALID_ARG_TYPE('options.appTicketData',
5235+
['ArrayBufferView'], appTicketData);
5236+
}
5237+
if (appTicketData.byteLength === 0) {
5238+
throw new ERR_INVALID_ARG_VALUE('options.appTicketData', appTicketData,
5239+
'must not be empty');
5240+
}
5241+
}
5242+
52225243
if (cc !== undefined) {
52235244
validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]);
52245245
}
@@ -5301,6 +5322,7 @@ function processSessionOptions(options, config = kEmptyObject) {
53015322
maxWindow,
53025323
sessionTicket,
53035324
token,
5325+
appTicketData,
53045326
cc,
53055327
datagramDropPolicy,
53065328
drainingPeriodMultiplier,

src/quic/application.cc

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,13 @@ std::optional<PendingTicketAppData> Session::Application::ParseTicketData(
225225
auto app_type =
226226
static_cast<Type>(reinterpret_cast<const uint8_t*>(data.base)[0]);
227227
switch (app_type) {
228-
case Type::DEFAULT:
229-
return DefaultTicketData{};
228+
case Type::DEFAULT: {
229+
// Everything after the leading type byte is opaque application data.
230+
DefaultTicketData dtd;
231+
const auto* p = reinterpret_cast<const uint8_t*>(data.base);
232+
if (data.len > 1) dtd.data.assign(p + 1, p + data.len);
233+
return dtd;
234+
}
230235
case Type::HTTP3:
231236
return ParseHttp3TicketData(data);
232237
default:
@@ -235,10 +240,11 @@ std::optional<PendingTicketAppData> Session::Application::ParseTicketData(
235240
}
236241

237242
bool Session::Application::ValidateTicketData(
238-
const PendingTicketAppData& data, const Application_Options& options) {
243+
const PendingTicketAppData& data, const Session::Options& session_options) {
239244
if (std::holds_alternative<Http3TicketData>(data)) {
240245
// TODO(@jasnell): This validation probably belongs in http3.cc but keeping
241246
// it here for now.
247+
const auto& options = session_options.application_options;
242248
const auto& ticket = std::get<Http3TicketData>(data);
243249
return options.max_field_section_size >= ticket.max_field_section_size &&
244250
options.qpack_max_dtable_capacity >=
@@ -250,8 +256,19 @@ bool Session::Application::ValidateTicketData(
250256
options.enable_connect_protocol) &&
251257
(!ticket.enable_datagrams || options.enable_datagrams);
252258
}
253-
// DefaultTicketData always validates.
254-
return true;
259+
if (std::holds_alternative<DefaultTicketData>(data)) {
260+
// Opaque app-data (raw QUIC / non-h3): the embedded bytes must exactly
261+
// match the server's currently-configured app_ticket_data.
262+
const auto& dtd = std::get<DefaultTicketData>(data);
263+
uv_buf_t cur = session_options.app_ticket_data.has_value()
264+
? static_cast<uv_buf_t>(*session_options.app_ticket_data)
265+
: uv_buf_init(nullptr, 0);
266+
return dtd.data.size() == cur.len &&
267+
(cur.len == 0 || memcmp(dtd.data.data(), cur.base, cur.len) == 0);
268+
}
269+
// Unknown/unparsed ticket data -> fail closed so no 0-RTT (falls back to
270+
// 1-RTT, so limited impact but avoids invalid resumptions).
271+
return false;
255272
}
256273

257274
Packet::Ptr Session::Application::CreateStreamDataPacket() {
@@ -734,6 +751,23 @@ class DefaultApplication final : public Session::Application {
734751
}
735752
}
736753

754+
void CollectSessionTicketAppData(
755+
SessionTicket::AppData* app_data) const override {
756+
// Layout: [type byte][optional opaque app data]. With no app data this
757+
// degenerates to the single type byte written by the base class.
758+
const auto& atd = session().config().options.app_ticket_data;
759+
uv_buf_t bytes =
760+
atd.has_value() ? static_cast<uv_buf_t>(*atd) : uv_buf_init(nullptr, 0);
761+
std::vector<uint8_t> buf;
762+
buf.reserve(1 + bytes.len);
763+
buf.push_back(static_cast<uint8_t>(type())); // Type::DEFAULT
764+
if (bytes.len > 0) {
765+
const auto* p = reinterpret_cast<const uint8_t*>(bytes.base);
766+
buf.insert(buf.end(), p, p + bytes.len);
767+
}
768+
app_data->Set(uv_buf_init(reinterpret_cast<char*>(buf.data()), buf.size()));
769+
}
770+
737771
bool ApplySessionTicketData(const PendingTicketAppData& data) override {
738772
return std::holds_alternative<DefaultTicketData>(data);
739773
}

src/quic/application.h

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <optional>
66
#include <variant>
7+
#include <vector>
78

89
#include "base_object.h"
910
#include "bindingdata.h"
@@ -17,7 +18,11 @@ namespace node::quic {
1718
// Parsed session ticket application data, produced by
1819
// Application::ParseTicketData() before ALPN negotiation and consumed
1920
// by Application::ApplySessionTicketData() after.
20-
struct DefaultTicketData {};
21+
struct DefaultTicketData {
22+
// The opaque application data carried in the ticket (after the type byte),
23+
// matched exactly against the server's current `app_ticket_data`.
24+
std::vector<uint8_t> data;
25+
};
2126
struct Http3TicketData {
2227
uint64_t max_field_section_size;
2328
uint64_t qpack_max_dtable_capacity;
@@ -163,12 +168,13 @@ class Session::Application : public MemoryRetainer {
163168
const SessionTicket::AppData& app_data,
164169
SessionTicket::AppData::Source::Flag flag);
165170

166-
// Validates parsed ticket data against current application options.
167-
// Returns false if the stored settings are more permissive than the
168-
// current config (e.g., a feature was enabled when the ticket was
169-
// issued but is now disabled).
171+
// Validates parsed ticket data against the current session configuration.
172+
// For HTTP/3 tickets this rejects settings more permissive than the
173+
// current config (e.g. a feature enabled when the ticket was issued but
174+
// now disabled); for default (opaque) tickets it requires the embedded data
175+
// to exactly match the configured app_ticket_data. Returns false to reject.
170176
static bool ValidateTicketData(const PendingTicketAppData& data,
171-
const Application_Options& options);
177+
const Session::Options& session_options);
172178

173179
// Parse session ticket app data before ALPN negotiation. Reads the
174180
// type byte and dispatches to the appropriate application-specific

src/quic/bindingdata.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class SessionManager;
7272
V(active_connection_id_limit, "activeConnectionIDLimit") \
7373
V(address_lru_size, "addressLRUSize") \
7474
V(allow, "allow") \
75+
V(app_ticket_data, "appTicketData") \
7576
V(application, "application") \
7677
V(authoritative, "authoritative") \
7778
V(bbr, "bbr") \

src/quic/session.cc

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,18 @@ Maybe<Session::Options> Session::Options::From(Environment* env,
710710
}
711711
}
712712

713+
// Parse the optional opaque application data to embed in session tickets.
714+
Local<Value> app_ticket_data_val;
715+
if (params->Get(env->context(), state.app_ticket_data_string())
716+
.ToLocal(&app_ticket_data_val) &&
717+
app_ticket_data_val->IsArrayBufferView()) {
718+
Store app_ticket_data_store;
719+
if (Store::From(app_ticket_data_val.As<ArrayBufferView>())
720+
.To(&app_ticket_data_store)) {
721+
options.app_ticket_data = std::move(app_ticket_data_store);
722+
}
723+
}
724+
713725
return Just<Options>(options);
714726
}
715727

@@ -2880,16 +2892,13 @@ SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
28802892
if (!parsed.has_value()) {
28812893
return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW;
28822894
}
2883-
// Pre-validate the ticket data against the current application options.
2884-
// If the stored settings are more permissive than the current config
2885-
// (e.g., a feature was enabled when the ticket was issued but is now
2886-
// disabled), reject the ticket so 0-RTT is not used. This must happen
2895+
// Pre-validate the ticket data against the current configuration. If it
2896+
// does not match, reject the ticket so 0-RTT is not used. This must happen
28872897
// here (during TLS ticket processing) rather than in SetApplication,
28882898
// because by SetApplication time the TLS layer has already accepted
28892899
// the ticket and told the client 0-RTT is ok.
2890-
if (!Application::ValidateTicketData(*parsed,
2891-
config().options.application_options)) {
2892-
Debug(this, "Session ticket app data incompatible with current settings");
2900+
if (!Application::ValidateTicketData(*parsed, config().options)) {
2901+
Debug(this, "Session ticket app data incompatible with current config");
28932902
return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW;
28942903
}
28952904
impl_->pending_ticket_data_ = std::move(parsed);

src/quic/session.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,13 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
240240
// to skip address validation. Client-side only.
241241
std::optional<Store> token;
242242

243+
// Opaque application data to embed in issued session tickets. On the
244+
// server this is both written into new tickets and used to validate
245+
// 0-RTT on resume (a resumed ticket whose app-data does not exactly match
246+
// this value has its early data rejected). Protocol-agnostic; the
247+
// built-in HTTP/3 application uses its own typed ticket data instead.
248+
std::optional<Store> app_ticket_data;
249+
243250
void MemoryInfo(MemoryTracker* tracker) const override;
244251
SET_MEMORY_INFO_NAME(Session::Options)
245252
SET_SELF_SIZE(Options)

0 commit comments

Comments
 (0)