Skip to content

Commit 4a310f7

Browse files
flubmatheus23
andauthored
feat(proto): Accept off-path probe packets (n0-computer#608)
## Description When sending probing packets we need to accept them and send responses. This currently does not try to track all 4-tuples the server should know, we only need this to work when qnt is enabled for now so I'm taking a shortcut and not storing that state (yet). This also fixes updating the 4-tuple too early: it would be update even when a packet could still be rejected. We now only update the 4-tuple once the packet is fully authenticated. It is made clear that earlier discards are only an optimisation. This now fully respects the various probing packet requirements of RFC9000, Multipath and QNT in all it's confusing combinations. Fixes n0-computer#599. ## Breaking Changes n/a ## Notes & open questions n/a ## Change checklist - [x] Self-review. - [x] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [x] Tests if relevant. --------- Co-authored-by: Philipp Krüger <philipp.krueger1@gmail.com>
1 parent 0c0e020 commit 4a310f7

4 files changed

Lines changed: 290 additions & 192 deletions

File tree

noq-proto/src/connection/mod.rs

Lines changed: 161 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,7 +2164,7 @@ impl Connection {
21642164
let span = trace_span!("pkt", %path_id);
21652165
let _guard = span.enter();
21662166

2167-
if self.update_network_path_or_discard(network_path, path_id) {
2167+
if self.early_discard_packet(network_path, path_id) {
21682168
// A return value of true indicates we should discard this packet.
21692169
return;
21702170
}
@@ -2225,13 +2225,27 @@ impl Connection {
22252225
}
22262226
}
22272227

2228-
/// Updates the network path for `path_id`.
2228+
/// Returns whether a packet can be discarded early.
22292229
///
2230-
/// Returns true if a packet coming in for this `path_id` over given `network_path` should be discarded.
2231-
/// Returns false if the path was updated and the packet doesn't need to be discarded.
2232-
fn update_network_path_or_discard(&mut self, network_path: FourTuple, path_id: PathId) -> bool {
2233-
let remote_may_migrate = self.side.remote_may_migrate(&self.state);
2234-
let local_ip_may_migrate = self.side.is_client();
2230+
/// Packets sent on the wrong network path can be entirely ignored, saving further
2231+
/// processing.
2232+
///
2233+
/// Returns true if a packet coming in for this `path_id` over given `network_path`
2234+
/// should be discarded.
2235+
fn early_discard_packet(&mut self, network_path: FourTuple, path_id: PathId) -> bool {
2236+
if self.is_handshaking() && path_id != PathId::ZERO {
2237+
debug!(%network_path, %path_id, "discarding multipath packet during handshake");
2238+
return true;
2239+
}
2240+
2241+
// TODO(flub): In RFC9000 the server is allowed to send off-path probing packets
2242+
// once the client has been probing such a 4-tuple. These probes are currently
2243+
// not yet recognised and discarded here.
2244+
// See https://github.com/n0-computer/noq/issues/607.
2245+
let remote_may_migrate = self.remote_may_migrate();
2246+
2247+
let local_ip_may_migrate = self.local_ip_may_migrate();
2248+
22352249
// If this packet could initiate a migration and we're a client or a server that
22362250
// forbids migration, drop the datagram. This could be relaxed to heuristically
22372251
// permit NAT-rebinding-like migration.
@@ -2259,35 +2273,54 @@ impl Connection {
22592273
);
22602274
return true;
22612275
}
2262-
// If the datagram indicates that we've changed our local IP, we update it.
2263-
// This is alluded to in Section 5.2 of the Multipath RFC draft 18:
2264-
// https://www.ietf.org/archive/id/draft-ietf-quic-multipath-18.html#name-using-multiple-paths-on-the
2265-
// > Client receives the packet, recognizes a path migration, updates the source address of path 2 to 192.0.2.1.
2266-
if let Some(local_ip) = network_path.local_ip {
2267-
if known_path
2268-
.network_path
2269-
.local_ip
2270-
.is_some_and(|ip| ip != local_ip)
2271-
{
2272-
debug!(
2273-
%path_id,
2274-
%network_path,
2275-
%known_path.network_path,
2276-
"path's local address seemingly migrated"
2277-
);
2276+
}
2277+
false
2278+
}
2279+
2280+
/// Whether a remote is allowed to migrate.
2281+
///
2282+
/// QUIC relies on stable endpoints during the handshake. So other than the server's
2283+
/// preferred_address transport parameter no side may migrate before the handshake is
2284+
/// completed.
2285+
///
2286+
/// In RFC9000 only the client may migrate. If QNT is negotiated the server may migrate
2287+
/// as well.
2288+
///
2289+
/// Additionally for iroh we allow the server to migrate once during the handshake as
2290+
/// long as the client has not received an authenticated Handshake packet. This allows
2291+
/// us to duplicate client Initial packets to multiple destinations. See
2292+
/// [`state::Handshake::allow_server_migration`].
2293+
fn remote_may_migrate(&self) -> bool {
2294+
match &self.side {
2295+
ConnectionSide::Server { server_config } => {
2296+
server_config.migration && self.is_handshake_confirmed()
2297+
}
2298+
ConnectionSide::Client { .. } => {
2299+
if let Some(hs) = self.state.as_handshake() {
2300+
hs.allow_server_migration
2301+
} else {
2302+
self.n0_nat_traversal.is_negotiated() && self.is_handshake_confirmed()
22782303
}
2279-
// We update the address without path validation on the client side.
2280-
// https://www.ietf.org/archive/id/draft-ietf-quic-multipath-18.html#section-5.1
2281-
// > Servers observing a 4-tuple change will perform path validation (see Section 9 of [QUIC-TRANSPORT]).
2282-
// This sounds like it's *only* the server endpoints that do this.
2283-
// TODO(matheus23): We should still consider doing a proper migration on the client side in the future.
2284-
// For now, this preserves the behavior of this code pre 4-tuple tracking.
2285-
known_path.network_path.local_ip = Some(local_ip);
22862304
}
22872305
}
2288-
false
22892306
}
22902307

2308+
/// Whether our local IP address is allowed to change with new incoming packets.
2309+
///
2310+
/// Incoming packets show us the local IP address we received a packet on, which could
2311+
/// be different from what we thought due to e.g. NAT rebinding or moving from mobile
2312+
/// data to WiFi without being notified of the network change.
2313+
///
2314+
/// This is only allowed to happen after the handshake is confirmed and when we are the
2315+
/// client. Unless QNT is negotiated in which case the server is also allowed to
2316+
/// migrate.
2317+
///
2318+
/// Be aware that probing packets, which do not exist in Multipath without QNT, are
2319+
/// exempt from this.
2320+
fn local_ip_may_migrate(&self) -> bool {
2321+
(self.side.is_client() || self.n0_nat_traversal.is_negotiated())
2322+
&& self.is_handshake_confirmed()
2323+
}
22912324
/// Process timer expirations
22922325
///
22932326
/// Executes protocol logic, potentially preparing signals (including application `Event`s,
@@ -4051,6 +4084,11 @@ impl Connection {
40514084
}
40524085
}
40534086

4087+
/// Decrypts the packet and processes the payload.
4088+
///
4089+
/// Processes the entire packet, starting with removing header protection, then handling
4090+
/// a stateless reset if needed, and decrypting and processing the frames in the payload
4091+
/// if not a stateless reset.
40544092
fn handle_decode(
40554093
&mut self,
40564094
now: Instant,
@@ -4076,6 +4114,12 @@ impl Connection {
40764114
}
40774115
}
40784116

4117+
/// Handles a packet with header protection removed.
4118+
///
4119+
/// The packet body is still encrypted at this point.
4120+
///
4121+
/// If the datagram was a stateless reset we may have failed to remove header protection
4122+
/// and thus `packet` may be `None`.
40794123
fn handle_packet(
40804124
&mut self,
40814125
now: Instant,
@@ -4098,31 +4142,10 @@ impl Connection {
40984142
);
40994143
}
41004144

4101-
if self.is_handshaking() {
4102-
if path_id != PathId::ZERO {
4103-
debug!(%network_path, %path_id, "discarding multipath packet during handshake");
4104-
return;
4105-
}
4106-
if network_path != self.path_data_mut(path_id).network_path {
4107-
if let Some(hs) = self.state.as_handshake() {
4108-
if hs.allow_server_migration {
4109-
trace!(%network_path, prev = %self.path_data(path_id).network_path, "server migrated to new remote");
4110-
self.path_data_mut(path_id).network_path = network_path;
4111-
self.qlog.emit_tuple_assigned(path_id, network_path, now);
4112-
} else {
4113-
debug!("discarding packet with unexpected remote during handshake");
4114-
return;
4115-
}
4116-
} else {
4117-
debug!("discarding packet with unexpected remote during handshake");
4118-
return;
4119-
}
4120-
}
4121-
}
4122-
41234145
let was_closed = self.state.is_closed();
41244146
let was_drained = self.state.is_drained();
41254147

4148+
// Now decrypt the packet payload in-place.
41264149
let decrypted = match packet {
41274150
None => Err(None),
41284151
Some(mut packet) => self
@@ -4152,13 +4175,34 @@ impl Connection {
41524175
}
41534176
}
41544177
Ok((packet, number)) => {
4178+
// We received an authenticated packet and decrypted it.
41554179
qlog.header(&packet.header, number, path_id);
41564180
let span = match number {
41574181
Some(pn) => trace_span!("recv", space = ?packet.header.space(), pn),
41584182
None => trace_span!("recv", space = ?packet.header.space()),
41594183
};
41604184
let _guard = span.enter();
41614185

4186+
// Now the packet is authenticated we do the migration during the
4187+
// handshake. See Handshake::allow_server_migration for details.
4188+
if self.is_handshaking() && network_path != self.path_data_mut(path_id).network_path
4189+
{
4190+
if let Some(hs) = self.state.as_handshake()
4191+
&& hs.allow_server_migration
4192+
{
4193+
trace!(
4194+
%network_path,
4195+
prev = %self.path_data(path_id).network_path,
4196+
"server migrated to new remote",
4197+
);
4198+
self.path_data_mut(path_id).network_path = network_path;
4199+
self.qlog.emit_tuple_assigned(path_id, network_path, now);
4200+
} else {
4201+
debug!("discarding packet with unexpected remote during handshake");
4202+
return;
4203+
}
4204+
}
4205+
41624206
let dedup = self.spaces[packet.header.space()]
41634207
.path_space_mut(path_id)
41644208
.map(|pns| &mut pns.dedup);
@@ -4714,7 +4758,7 @@ impl Connection {
47144758
Ok(())
47154759
}
47164760

4717-
/// Processes the packet payload, always in the data space.
4761+
/// Processes the decrypted packet payload, always in the data space.
47184762
fn process_payload(
47194763
&mut self,
47204764
now: Instant,
@@ -4823,18 +4867,17 @@ impl Connection {
48234867
.path_mut(path_id)
48244868
.expect("payload is processed only after the path becomes known");
48254869
path.path_responses.push(number, challenge.0, network_path);
4826-
// At this point, update_network_path_or_discard was already called, so
4827-
// we don't need to be lenient about `local_ip` possibly mis-matching.
4828-
if network_path == path.network_path {
4829-
// PATH_CHALLENGE on active path, possible off-path packet forwarding
4830-
// attack. Send a non-probing packet to recover the active path.
4831-
// TODO(flub): No longer true! We now path_challege also to validate
4832-
// the path if the path is new, without an RFC9000-style
4833-
// migration involved. This means we add in an extra
4834-
// IMMEDIATE_ACK on some challenges. It isn't really wrong to do
4835-
// so, but it still is something untidy. We should instead
4836-
// suppress this when we know the remote is still validating the
4837-
// path.
4870+
// If we were passively migrated (e.g. NAT rebinding), our local_ip will
4871+
// not match. Once we processed a non-probing packet the local_ip will
4872+
// finally be updated.
4873+
if network_path.remote == path.network_path.remote {
4874+
// PATH_CHALLENGE on active path, possible off-path packet
4875+
// forwarding attack. Send a non-probing packet to recover the
4876+
// active path. See
4877+
// https://www.rfc-editor.org/rfc/rfc9000.html#section-9.3.3-3. In
4878+
// rare cases NAT probes might also appear on-path and would also
4879+
// get a non-probing packet as response. There is little harm in
4880+
// this.
48384881
match self.peer_supports_ack_frequency() {
48394882
true => self.immediate_ack(path_id),
48404883
false => {
@@ -5454,20 +5497,48 @@ impl Connection {
54545497
self.connection_close_pending = true;
54555498
}
54565499

5457-
if Some(number)
5500+
// For Multipath any packet triggers migration. For RFC9000 or QNT (+ Multipath)
5501+
// only non-probing packets trigger migration.
5502+
let migrate_on_any_packet =
5503+
self.is_multipath_negotiated() && !self.n0_nat_traversal.is_negotiated();
5504+
5505+
// Only migrate if this is the largest packet number seen.
5506+
let is_largest_received_pn = Some(number)
54585507
== self.spaces[SpaceId::Data]
54595508
.for_path(path_id)
5460-
.largest_received_packet_number
5461-
&& !is_probing_packet
5462-
&& network_path != self.path_data(path_id).network_path
5509+
.largest_received_packet_number;
5510+
5511+
// If we receive a non-probing packet on a new local IP that means we had a NAT
5512+
// rebinding-like migration. We update our local address but do not otherwise
5513+
// validate the new path, we only need to validate the path if the peer migrates per
5514+
// RFC9000 §9: https://www.rfc-editor.org/rfc/rfc9000.html#section-9-4
5515+
if (migrate_on_any_packet || !is_probing_packet)
5516+
&& is_largest_received_pn
5517+
&& self.local_ip_may_migrate()
5518+
&& let Some(new_local_ip) = network_path.local_ip
5519+
{
5520+
let path_data = self.path_data_mut(path_id);
5521+
if path_data
5522+
.network_path
5523+
.local_ip
5524+
.is_some_and(|ip| ip != new_local_ip)
5525+
{
5526+
debug!(
5527+
%path_id,
5528+
new_4tuple = %network_path,
5529+
prev_4tuple = %path_data.network_path,
5530+
"local address passive migration"
5531+
);
5532+
}
5533+
path_data.network_path.local_ip = Some(new_local_ip)
5534+
}
5535+
5536+
// If the peer migrated to a new address, trigger migration.
5537+
if (migrate_on_any_packet || !is_probing_packet)
5538+
&& is_largest_received_pn
5539+
&& network_path.remote != self.path_data(path_id).network_path.remote
5540+
&& self.remote_may_migrate()
54635541
{
5464-
let ConnectionSide::Server { ref server_config } = self.side else {
5465-
panic!("packets from unknown remote should be dropped by clients");
5466-
};
5467-
debug_assert!(
5468-
server_config.migration,
5469-
"migration-initiating packets should have been dropped immediately"
5470-
);
54715542
self.migrate(path_id, now, network_path, migration_observed_addr);
54725543
// Break linkability, if possible
54735544
self.update_remote_cid(path_id);
@@ -5477,14 +5548,23 @@ impl Connection {
54775548
Ok(())
54785549
}
54795550

5551+
/// Migrates the 4-tuple of the path.
5552+
///
5553+
/// This creates a new [`PathData`] for the migrated path and stores the previous
5554+
/// [`PathData`] in [`PathState::prev`].
54805555
fn migrate(
54815556
&mut self,
54825557
path_id: PathId,
54835558
now: Instant,
54845559
network_path: FourTuple,
54855560
observed_addr: Option<ObservedAddr>,
54865561
) {
5487-
trace!(%network_path, %path_id, "migration initiated");
5562+
trace!(
5563+
new_4tuple = %network_path,
5564+
prev_4tuple = %self.path_data(path_id).network_path,
5565+
%path_id,
5566+
"migration initiated",
5567+
);
54885568
self.path_generation_counter = self.path_generation_counter.wrapping_add(1);
54895569
// TODO(@divma): conditions for path migration in multipath are very specific, check them
54905570
// again to prevent path migrations that should actually create a new path
@@ -5648,7 +5728,9 @@ impl Connection {
56485728
};
56495729

56505730
if open_first && let Err(e) = self.open_path(network_path, status, now) {
5651-
debug!(%e,"Failed to open new path for network change");
5731+
if self.side().is_client() {
5732+
debug!(%e, "Failed to open new path for network change");
5733+
}
56525734
// if this fails, let the path try to recover itself
56535735
recoverable_paths.push((path_id, remote));
56545736
continue;
@@ -7252,19 +7334,6 @@ enum ConnectionSide {
72527334
}
72537335

72547336
impl ConnectionSide {
7255-
fn remote_may_migrate(&self, state: &State) -> bool {
7256-
match self {
7257-
Self::Server { server_config } => server_config.migration,
7258-
Self::Client { .. } => {
7259-
if let Some(hs) = state.as_handshake() {
7260-
hs.allow_server_migration
7261-
} else {
7262-
false
7263-
}
7264-
}
7265-
}
7266-
}
7267-
72687337
fn is_client(&self) -> bool {
72697338
self.side().is_client()
72707339
}

0 commit comments

Comments
 (0)