Flexible protocol negotiation

The protocol negotiation docs explain how a connection is upgraded for encryption and muxing, then it explains how application protocols are negotiated on the muxed streams. Why is this second part separated and treated differently from the first? Is it some sort of architectual model? It seems to me that separating connection upgrades as libp2p modules is an unnecessary abstraction that limits the user’s choices.

For example what if the user didn’t really care about encryption but they wanted to keep the muxer? An example of this use case is unix domain sockets. (I know we could use a plain text encryption module but that is besides the point.) What about if the user wants something additional layered in the system like a compression algorithm?

Some sudo-code to help explain what I was expecting the code to look like:

const connection = await node.dial(peer);

await connection.upgrade(["/noise", "/tls"]);
await connection.upgrade(["/gzip"]);
await connection.upgrade(["/mplex"]);

const stream = await mss.handle(connection, ["/my-custom-protocol"]);

await stream.upgrade(["/custom-auth"]);

// Use stream

Of course this is a little verbose and defaults could be set to make this easier but this would grant a lot of flexibility to the protocol and allow the user pick and choose modules and stack them based on what they need. I could also see that this might help with more difficult transports like UDP or Sneakernet.

All this is in the context of the JavaScript libp2p library but from the specification it looks like this is the standard for the rest as well.

As a side note this sort of behavior can be layered on to virtual streams in the current JS implementation but suffers a little bit of inefficiency from doubling the multiplexing. For example:

const createCompressionDuplex = duplex => ({
	sink: source => pipe(source, deflate, duplex),
	source: pipe(duplex, enflate)
});

server.handle("/compression", ({ stream }) => {
	// Create a duplex streaming iterable that transforms the intput and output.
	const processedStream = createCompressionDuplex(stream);

	mss.handle(processedStream, ["/echo"]).then(({ stream }) => {
		return pipe(stream, stream);
	});
});

client.dialProtocol(peerId, "/compression").then(stream => {
	const processedStream = createCompressionDuplex(stream);

	return mss.select(customStream, ["/echo"])
}).then(stream => {
	// Use the /echo protocol over the /compression protocol.
});

FYI this is implementation details and not really protocols, the rust implementation looks more less like you show while the go one use (optional) virtual reverse bindings (which rust cannot do).

I think the main reason the pseudo code you seen isn’t how you use go-libp2p today is because go-libp2p aims to be a high level and having to manually do your negotiation isn’t high level.

In theory you could code the unix transport to use the insecure security protocol no one did so yet.

How go-libp2p do things internally:

There is lots of glue for config options and so on, what go-libp2p do is essentially just this with many edge cases, glue logic and some magic (reflection):

const tls = newTls(privateKey)
const noise = newNoise(privateKey)
const security = newSecurityUpgrader([tls, noise])

const mplex = newMplex()
const yamux = newYamux()
const muxing = newMuxingUpgrader([yamux, mplex])

const tcp = newTcpTransport(security, muxing)

// ...
const conn = await tcp.Listen(addrListen)
// ...
const conn = await tcp.Dial(addrDial)

// Other transports are "battery included" like QUIC and do security and or muxing themself
const quic = newQuicTransport(privateKey)

If you want you could “just” do:

const tls = newTls(privateKey)
const noise = newNoise(privateKey)
const unsecure = newUnsecure(privateKey) // this will literally just send plaintext, the private key is needed to send our public key to the remote node
const security = newSecurityUpgrader([unsecure, tls, noise])

const mplex = newMplex()
const yamux = newYamux()
const muxing = newMuxingUpgrader([yamux, mplex])

const unix = newUnixTransport(security, muxing) // create a new unix transport with the unsecure "security" protocol as a first option

In practice this code is go-libp2p internals and there is no option to configure which upgrader is used on which transport (only everything on anything).

You could code (and send PRs) go-libp2p to have more granular options if you want.


await connection.upgrade(["/noise", "/tls"]);
await connection.upgrade(["/gzip"]);
await connection.upgrade(["/mplex"]);

This code is actually unsafe if there is attacker reflectable content in the streams (due to /gzip working on plaintext, this can be used to diff potential contents and find correlations up to completely revealing the plaintext).

You can read more about this here: BREACH - Wikipedia

The main solution to this is to never compress private content and reflectable content together, however this require is application specific knowledge, which only application have. (you should handle compressing or not at the request level, for example bitswap could compress blocks individually)

1 Like