libp2p / go-libp2p

libp2p implementation in Go
MIT License
5.83k stars 1.04k forks source link

quic: use context to allow sending of 0-RTT data #1533

Open marten-seemann opened 3 years ago

marten-seemann commented 3 years ago

Inspired by @vyzo’s and @aarshkshah1992’s recent approach of adding a value to a context.Context to signal if a stream should be opened via a relay, I'm wondering if a similar approach make sense for making use of QUIC’s 0-RTT feature?

Background

When re-connecting to a server we've connected to before, QUIC allows us to send application data right away, without waiting for the handshake to finish. Data is encrypted with a key derived from the key used on the previous connection. However, 0-RTT data can be replayed by an attacker, and is therefore only safe to be used for idempotent data.

The doc linked in https://github.com/libp2p/go-libp2p/issues/1532 discusses approaches to make 0-RTT replay-safe. These approaches work, as long as the server properly implements them, however, there's no way for the client to enforce or even check this, so we probably don't want to rely on this. A safer approach is to only use 0-RTT for application data that's replay-safe.

Proposal

Make Dial return immediately when a 0-RTT connection is dialed. OpenStream will block until the handshake is done, unless a special "0-RTT-safe" value is set on the context, in which case it will return immediately. An application can then write 0-RTT data on that stream. After writing the idempotent data, it can then use context returned by HandshakeComplete() on the QUIC connection to wait for the QUIC handshake to complete. Note that we'd have to add a similar method to the MuxedConn interface (or maybe to the MuxedStream?

By having application explicitly opt-in, we make sure that the default behavior of libp2p is a sane default: only send application data when the connection is properly secured and replay-safe. Furthermore, by using a special value on a context, this will naturally work on transports that don't support 0-RTT data.

Open Questions

0-RTT can be rejected by the server for various reasons. In that case, the handshake falls back to a normal 1-RTT handshake, and all data sent in 0-RTT is ignored. This also invalidates all streams opened.

cc @Stebalien

marten-seemann commented 3 years ago

Dealing with 0-RTT Rejection

There are 2 options for dealing with 0-RTT rejection that come to mind:

  1. Let the application deal with it, by returning an ErrZeroRTTRefused whenever any function on this stream is called. It's the application's responsibility to handle this error, most likely by opening a new stream and sending out the data again.
  2. Return an abstracted stream to the application: as long as the handshake is not complete, we keep a copy of all data sent out on that stream. If 0-RTT is rejected, we just open a new stream and write the buffered data there.

I'm leaning towards option 2. While more work to implement, it would be completely transparent to the application. We don't really need to worry about the state accumulated here since 1. we only need to keep that state for the duration of the handshake (which is bounded by the handshake timeout, 5 or 10s) and 2. the amount of 0-RTT data we can send is effectively bounded by the initial congestion window.

vyzo commented 3 years ago

I don't think 2 is the right option here; the application has to explicitly opt-in to 0-RTT, so it has to be prepared to handle early rejection as well. Let's take the UNIX approach and go with 1.

marten-seemann commented 3 years ago

The question we need to answer is what most applications would want? Would they want to write different data if 0-RTT is rejected, or will (almost) every application just retransmit the data? Maybe we should think about libp2p's primary use case for 0-RTT, which is performing fast DHT operations. How would we want to use it there?

I expect that for the majority of applications not a lot will change within the one RTT the handshake takes, so they'd just retransmit that data. If the need occurs, we could implement both options here, and have the application pick between both behaviors by another option on the context.

Stebalien commented 3 years ago

Note: we went with this approach for relays because it was easy, not because it was the "best" way. I'm still open to doing it here, but this is kind of on the edge. I consider "allow relays" and "don't allow relays" to be hints, but 0-RTT is more than that: it's dropping a security guarantee.

I expect that for the majority of applications not a lot will change within the one RTT the handshake takes, so they'd just retransmit that data. If the need occurs, we could implement both options here, and have the application pick between both behaviors by another option on the context.

I agree. Note: we could also have an explicit "require zero rtt" if necessary, but I can't think of a use-case.

marten-seemann commented 3 years ago

but 0-RTT is more than that: it's dropping a security guarantee.

That's why I suggested an opt-in. If an application doesn't change anything, it won't get access to a 0-RTT enabled stream, so the same strong security guarantees continue to apply.

I'm still open to doing it here, but this is kind of on the edge.

This was just a proposal to get it shipped rather sooner than later. I'm open to making an API change, if we can agree on the right course. For QUIC, it would be sufficient to add a HandshakeComplete() and anOpen0RTTStream to the MuxedConn. For TLS 1.3 / Noise over TCP, this would probably not be enough. The primary value of using early data in that case would be to speed up the stream muxer negotiation, which brings us dangerously close to the ms2.0 discussion.

Stebalien commented 3 years ago

That's why I suggested an opt-in. If an application doesn't change anything, it won't get access to a 0-RTT enabled stream, so the same strong security guarantees continue to apply.

My point is that contexts are very... implicit. It would be easy for a higher-level service to say "0-RTT" is fine and add the option to their context. Then, they'd pass the context down to the lower-level service, using 0-RTT.

This is fine for now, but I'd like to eventually move to proper stream options.