Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion doc/api/quic.md
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,9 @@ Two pieces of state from a prior connection make this possible:
If the server accepts the session ticket, any data sent before the handshake
completes is 0-RTT early data. On the server side, `stream.early` is `true`
for streams carrying early data. The server can reject the 0-RTT attempt
(for example, if its configuration has changed since the ticket was issued).
(for example, if its configuration has changed since the ticket was issued);
[`sessionOptions.appTicketData`][] lets a server gate this explicitly, rejecting
early data whose ticket does not exactly match the server's current value.
When this happens, all streams opened during the 0-RTT phase are destroyed and
the client's [`session.onearlyrejected`][] callback fires. The connection
falls back to a normal 1-RTT handshake and the application can reopen streams.
Expand Down Expand Up @@ -2880,6 +2882,26 @@ await listen((session) => { /* ... */ }, {
});
```

#### `sessionOptions.appTicketData` (server only)

<!-- YAML
added: REPLACEME
-->

* Type: {ArrayBufferView}

Opaque application data to embed in the session tickets this server issues.
On resumption, the data carried by the presented ticket is compared
against the value currently configured here; if it does not match exactly,
the ticket's 0-RTT early data is rejected and the connection falls back to a
full 1-RTT handshake. Use it to bind 0-RTT acceptance to server-side state
that must agree between the original and resumed connection - rotating the
value invalidates the 0-RTT of all previously issued tickets.

This applies to the default QUIC application. HTTP/3 sessions carry their own
session-ticket data, so `appTicketData` is ignored when the negotiated ALPN
is `h3`.

#### `sessionOptions.ca` (client only)

<!-- YAML
Expand Down Expand Up @@ -4493,6 +4515,7 @@ throughput issues caused by flow control.
[`session.onsessionticket`]: #sessiononsessionticket
[`session.onstream`]: #sessiononstream
[`session.sendDatagram()`]: #sessionsenddatagramdatagram-encoding
[`sessionOptions.appTicketData`]: #sessionoptionsappticketdata-server-only
[`sessionOptions.cc`]: #sessionoptionscc
[`sessionOptions.ciphers`]: #sessionoptionsciphers
[`sessionOptions.datagramDropPolicy`]: #sessionoptionsdatagramdroppolicy
Expand Down
22 changes: 22 additions & 0 deletions lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,10 @@ const endpointRegistry = new SafeSet();
* prior session, used to resume that session (client only).
* @property {ArrayBufferView} [token] An opaque address validation token
* previously received from the server via `onnewtoken` (client only).
* @property {ArrayBufferView} [appTicketData] Opaque application data to embed
* in issued session tickets (server only). This is written into new tickets,
* and validated against received 0-RTT early data tickets to confirm if they
* can be accepted (anything but an exact match is rejected).
* @property {bigint|number} [handshakeTimeout] The handshake timeout
* @property {bigint|number} [initialRtt] The initial round-trip time estimate in milliseconds.
* Used for PTO computation and initial pacing before the first RTT sample. Default uses
Expand Down Expand Up @@ -5165,6 +5169,7 @@ function processSessionOptions(options, config = kEmptyObject) {
qlog = false,
sessionTicket,
token,
appTicketData,
maxPayloadSize,
unacknowledgedPacketThreshold = 0,
handshakeTimeout,
Expand Down Expand Up @@ -5219,6 +5224,22 @@ function processSessionOptions(options, config = kEmptyObject) {
}
}

if (appTicketData !== undefined) {
if (!forServer) {
throw new ERR_INVALID_ARG_VALUE(
'options.appTicketData', appTicketData,
'is only supported for server sessions');
}
if (!isArrayBufferView(appTicketData)) {
throw new ERR_INVALID_ARG_TYPE('options.appTicketData',
['ArrayBufferView'], appTicketData);
}
if (appTicketData.byteLength === 0) {
throw new ERR_INVALID_ARG_VALUE('options.appTicketData', appTicketData,
'must not be empty');
}
}

if (cc !== undefined) {
validateOneOf(cc, 'options.cc', [CC_ALGO_RENO, CC_ALGO_BBR, CC_ALGO_CUBIC]);
}
Expand Down Expand Up @@ -5301,6 +5322,7 @@ function processSessionOptions(options, config = kEmptyObject) {
maxWindow,
sessionTicket,
token,
appTicketData,
cc,
datagramDropPolicy,
drainingPeriodMultiplier,
Expand Down
44 changes: 39 additions & 5 deletions src/quic/application.cc
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,13 @@ std::optional<PendingTicketAppData> Session::Application::ParseTicketData(
auto app_type =
static_cast<Type>(reinterpret_cast<const uint8_t*>(data.base)[0]);
switch (app_type) {
case Type::DEFAULT:
return DefaultTicketData{};
case Type::DEFAULT: {
// Everything after the leading type byte is opaque application data.
DefaultTicketData dtd;
const auto* p = reinterpret_cast<const uint8_t*>(data.base);
if (data.len > 1) dtd.data.assign(p + 1, p + data.len);
return dtd;
}
case Type::HTTP3:
return ParseHttp3TicketData(data);
default:
Expand All @@ -235,10 +240,11 @@ std::optional<PendingTicketAppData> Session::Application::ParseTicketData(
}

bool Session::Application::ValidateTicketData(
const PendingTicketAppData& data, const Application_Options& options) {
const PendingTicketAppData& data, const Session::Options& session_options) {
if (std::holds_alternative<Http3TicketData>(data)) {
// TODO(@jasnell): This validation probably belongs in http3.cc but keeping
// it here for now.
const auto& options = session_options.application_options;
const auto& ticket = std::get<Http3TicketData>(data);
return options.max_field_section_size >= ticket.max_field_section_size &&
options.qpack_max_dtable_capacity >=
Expand All @@ -250,8 +256,19 @@ bool Session::Application::ValidateTicketData(
options.enable_connect_protocol) &&
(!ticket.enable_datagrams || options.enable_datagrams);
}
// DefaultTicketData always validates.
return true;
if (std::holds_alternative<DefaultTicketData>(data)) {
// Opaque app-data (raw QUIC / non-h3): the embedded bytes must exactly
// match the server's currently-configured app_ticket_data.
const auto& dtd = std::get<DefaultTicketData>(data);
uv_buf_t cur = session_options.app_ticket_data.has_value()
? static_cast<uv_buf_t>(*session_options.app_ticket_data)
: uv_buf_init(nullptr, 0);
return dtd.data.size() == cur.len &&
(cur.len == 0 || memcmp(dtd.data.data(), cur.base, cur.len) == 0);
}
// Unknown/unparsed ticket data -> fail closed so no 0-RTT (falls back to
// 1-RTT, so limited impact but avoids invalid resumptions).
return false;
}

Packet::Ptr Session::Application::CreateStreamDataPacket() {
Expand Down Expand Up @@ -734,6 +751,23 @@ class DefaultApplication final : public Session::Application {
}
}

void CollectSessionTicketAppData(
SessionTicket::AppData* app_data) const override {
// Layout: [type byte][optional opaque app data]. With no app data this
// degenerates to the single type byte written by the base class.
const auto& atd = session().config().options.app_ticket_data;
uv_buf_t bytes =
atd.has_value() ? static_cast<uv_buf_t>(*atd) : uv_buf_init(nullptr, 0);
std::vector<uint8_t> buf;
buf.reserve(1 + bytes.len);
buf.push_back(static_cast<uint8_t>(type())); // Type::DEFAULT
if (bytes.len > 0) {
const auto* p = reinterpret_cast<const uint8_t*>(bytes.base);
buf.insert(buf.end(), p, p + bytes.len);
}
app_data->Set(uv_buf_init(reinterpret_cast<char*>(buf.data()), buf.size()));
}

bool ApplySessionTicketData(const PendingTicketAppData& data) override {
return std::holds_alternative<DefaultTicketData>(data);
}
Expand Down
18 changes: 12 additions & 6 deletions src/quic/application.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include <optional>
#include <variant>
#include <vector>

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

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

// Parse session ticket app data before ALPN negotiation. Reads the
// type byte and dispatches to the appropriate application-specific
Expand Down
1 change: 1 addition & 0 deletions src/quic/bindingdata.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class SessionManager;
V(active_connection_id_limit, "activeConnectionIDLimit") \
V(address_lru_size, "addressLRUSize") \
V(allow, "allow") \
V(app_ticket_data, "appTicketData") \
V(application, "application") \
V(authoritative, "authoritative") \
V(bbr, "bbr") \
Expand Down
23 changes: 16 additions & 7 deletions src/quic/session.cc
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,18 @@ Maybe<Session::Options> Session::Options::From(Environment* env,
}
}

// Parse the optional opaque application data to embed in session tickets.
Local<Value> app_ticket_data_val;
if (params->Get(env->context(), state.app_ticket_data_string())
.ToLocal(&app_ticket_data_val) &&
app_ticket_data_val->IsArrayBufferView()) {
Store app_ticket_data_store;
if (Store::From(app_ticket_data_val.As<ArrayBufferView>())
.To(&app_ticket_data_store)) {
options.app_ticket_data = std::move(app_ticket_data_store);
}
}

return Just<Options>(options);
}

Expand Down Expand Up @@ -2880,16 +2892,13 @@ SessionTicket::AppData::Status Session::ExtractSessionTicketAppData(
if (!parsed.has_value()) {
return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW;
}
// Pre-validate the ticket data against the current application options.
// If the stored settings are more permissive than the current config
// (e.g., a feature was enabled when the ticket was issued but is now
// disabled), reject the ticket so 0-RTT is not used. This must happen
// Pre-validate the ticket data against the current configuration. If it
// does not match, reject the ticket so 0-RTT is not used. This must happen
// here (during TLS ticket processing) rather than in SetApplication,
// because by SetApplication time the TLS layer has already accepted
// the ticket and told the client 0-RTT is ok.
if (!Application::ValidateTicketData(*parsed,
config().options.application_options)) {
Debug(this, "Session ticket app data incompatible with current settings");
if (!Application::ValidateTicketData(*parsed, config().options)) {
Debug(this, "Session ticket app data incompatible with current config");
return SessionTicket::AppData::Status::TICKET_IGNORE_RENEW;
}
impl_->pending_ticket_data_ = std::move(parsed);
Expand Down
7 changes: 7 additions & 0 deletions src/quic/session.h
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
// to skip address validation. Client-side only.
std::optional<Store> token;

// Opaque application data to embed in issued session tickets. On the
// server this is both written into new tickets and used to validate
// 0-RTT on resume (a resumed ticket whose app-data does not exactly match
// this value has its early data rejected). Protocol-agnostic; the
// built-in HTTP/3 application uses its own typed ticket data instead.
std::optional<Store> app_ticket_data;

void MemoryInfo(MemoryTracker* tracker) const override;
SET_MEMORY_INFO_NAME(Session::Options)
SET_SELF_SIZE(Options)
Expand Down
Loading
Loading