go-libp2p
go-libp2p copied to clipboard
noise: define API for early data
We want to be able to send "early data" in our handshake message payloads, so that we can advertise supported stream multiplexers without adding round-trip negotiations after the handshake completes.
So far, we've defined the protobuf field in the handshake payload that will carry the early data, but we also need a way to provide it when configuring the transport, and a way to retrieve the early data sent by the remote party.
For setting the early data, we could have a new interface that early data capable SecureTransport
s would implement, e.g.
type EarlyDataSender interface {
func SetEarlyDataPayload([]byte)
}
Then when go-libp2p instantiates the security modules for a Host, it can type assert to see if they support this interface and inject the payload with the set of multiplexers.
We also need another interface that early data capable SecureConn
implementations would conform to, something like
type EarlyDataReceiver interface {
func RemoteEarlyData() []byte
}
Then multiselect 2 could type assert on the connection & try to get the set of muxers out if the conn supports early data.
The actual interfaces should probably be defined elsewhere, since they'll be common to any crypto channel that supports early data (e.g. TLS 1.3).
When implementing, we need to take care that the early data is sent in a handshake message that supports encrypted payloads. This essentially just means we can't send it in the initiator's first XX
message.
Now that I've typed this up, I'm starting to question whether the method to set the early data payload belongs on the transport itself, or whether it should be specialized for each connection. I was thinking that it doesn't need to be specialized, since the set of muxers is fairly static. But QUIC doesn't need a muxer, so we might want to skip sending the payload for QUIC connections. In that case, we'd need to define a new flavor of the SecureTransport
interface that allows passing in the early data payload for each connection.
Thank you writing this up @yusefnapora. Not sure if this is the right repo to have this discussion, it seems like the API we're defining applies to all transports. Maybe we should move this to go-libp2p (or go-libp2p-core?).
I recently implemented Early Data in quic-go, and also started work on implementing a 0-RTT API API for quic-go (which doesn't yet correctly deal with 0-RTT rejections though, see below). Since early data and 0-RTT are similar use cases, I'd suggest that we consider them both when designing our API (even if we defer implementation of 0-RTT).
Early Data
I chose to expose the whole net.Conn
(or rather, quic-go's equivalent to it, the quic.Session
) early, rather than providing getter and setter function to send and receive early data.
For the receiver, early data is not that different from post-handshake data. More specifically, on the receiver side, in the case of TLS 1.3, early data uses the same encryption key anyway, so there's the client has no reliable way to distinguish between early data and post-handshake data anyway. In my understanding, the same applies in Noise, at least as long as we're not trying to send unencrypted data (which we are not). On the sender side, it's nice to have the fully-fledged connection object available, since you're getting connection metadata (like RemoteAddr()
) for free, and more importantly, you'll have an io.Writer
.
I thought it would be a good idea to design an API as close to the standard library's TLS implementation. There tls.Listen
gives you a net.Listener
which returns net.Conn
s as soon as the TLS handshake completes. To enable sending of early data, we can define an EarlyListener
, i.e. net.Listener
that returns a net.Conn
as soon as it's possible to send early data (i.e. right after receiving the ClientHello).
The only thing missing is from this net.Conn
is a way to tell when the handshake completes. That's what I've added an additional function for to the interface:
type EarlyConn interface {
net.Conn
// Blocks until the handshake completes (or fails).
// Data sent before completion of the handshake is encrypted with 1-RTT keys.
// Note that the client's identity hasn't been verified yet.
HandshakeComplete() context.Context
}
The way you would use this (replacing quic.Session
by net.Conn
for easier readability here):
conn, _ := earlyListener.Accept()
_, _ = conn.Write(/* early data */)
// Block until we've verified the client's identity.
<-conn.HandshakeComplete().Done()
_, _ = conn.Write(/* application data */)
Equivalently, in the case of QUIC, you'd be able to open (multiple) streams on the the quic.Session
returned by earlyListener.Accept()
.
0-RTT
0-RTT is a tricky beast. Conceptually, the common case is easy: We need an EarlyDialer
that returns an EarlyConn
. The application can then write data on the EarlyConn
which is sent out as 0-RTT, and block on HandshakeComplete
to wait for completion of the handshake.
https://github.com/libp2p/specs/pull/227 defines how we want to use 0-RTT in libp2p: A client is required to remember the stream multiplexer for a 0-RTT connection. A client then can send multiplexed application data in its first flight. This makes sense because we don't expect stream multiplexers to change frequently, and we would just reject 0-RTT and fall back to the normal handshake in this corner case. Rejecting 0-RTT is defined in section 4.2.10 of RFC 8446. It is very instructive to read that section of the RFC.
What makes 0-RTT complicated is the fact that the server might not accept 0-RTT. There are mutliple reasons why a server would do so, and none of them is predictable by the client. One reason could be that the server forgot the key that it used to encrypt the session ticket. Another reason might be that the application protocol changed, or some parameters of the application protocol (in our case, the stream multiplexer might not available any more).
In the general case (see the TLS RFC linked above), all the data written becomes invalid, and a TLS stack can't retransmit that data (consider the case where a client 0-RTTs a connection using application protocol A
. The server doesn't support A
any more, so it rejects 0-RTT. Falling back to the normal handshake, client and server agree on protocol B
using ALPN. It doesn't make any sense for the client to retransmit the data sent for application protocol A
now).
We need to decide if there will ever be a case like that in libp2p. We might be able to argue that since libp2p doesn't use ALPN (we might just use a fake ALPN value to mask our traffic), the above mentioned scenario does not apply. For example, if 0-RTT is rejected because the stream multiplexer change, our stack can just retransmit the data that the application wrote using the newly negotiated stream multiplexer. This would simplify our API and application logic significantly, since 0-RTT rejects are then something that the application would never have to deal with.
Proposal for a libp2p API
If we decide to take a similar approach in libp2p, we'd probably have to extend the SecureConn
interface:
type EarlySecureConn interface {
SecureConn
HandshakeComplete() context.Context
}
Since 0-RTT resumes a connection including the multiplexer, we need to expose a multiplexed connection as soon as the client calls Dial
:
type EarlyMuxedConn interface {
MuxedConn
HandshakeComplete() context.Context
}
Of course, we also have to extend / modify the respective listener and dialer interfaces that will return the EarlySecureConn
and the EarlyMuxedConn
.
Thanks @marten-seemann, and sorry I forgot to tag you. I agree that we should probably move this to either go-libp2p or go-libp2p-core.
I like how the EarlySecureConn
interface exposes the full Conn
interface, and that from the receiver's end there's no real difference between reading early data and data that's sent after the handshake completes. That seems much cleaner than my EarlyDataReceiver
thing.
It seems like data sent through an EarlySecureConn
could potentially be sent either before or after the handshake completes, depending on the timing. For example, if we initiate a 1-RTT IK
handshake and immediately return an EarlySecureConn
, the handshake message which could contain the early data payload may have already been sent by the time someone calls Write
, so we'd end up buffering the data until the handshake completes. This seems fine from a security perspective, since you'd get better security guarantees after the handshake finishes. It just shifts my mental model a bit regarding early data, since I'd been thinking of it as always being sent before the handshake completes.
cc @raulk, since he may have opinions about this sort of thing 😄
It seems like data sent through an EarlySecureConn could potentially be sent either before or after the handshake completes, depending on the timing. [...] It just shifts my mental model a bit regarding early data, since I'd been thinking of it as always being sent before the handshake completes.
@yusefnapora Maybe this resolves @raulk's comment in https://github.com/libp2p/specs/pull/227/files#r351329177? For an implementation that doesn't support sending in early data, it's perfectly valid to send the list of stream multiplexers one roundtrip later.
Closing this as it's addressed by https://github.com/libp2p/specs/pull/453 and implemented by https://github.com/libp2p/go-libp2p/pull/1813