diff --git a/libp2p/__init__.py b/libp2p/__init__.py index 2772bed7..682ae27c 100644 --- a/libp2p/__init__.py +++ b/libp2p/__init__.py @@ -2,6 +2,7 @@ import asyncio import multiaddr from Crypto.PublicKey import RSA +from libp2p.security.insecure_security import InsecureTransport from .peer.peerstore import PeerStore from .peer.id import id_from_public_key from .network.swarm import Swarm @@ -75,7 +76,7 @@ def initialize_default_swarm( # TODO TransportUpgrader is not doing anything really # TODO parse muxer and sec to pass into TransportUpgrader muxer = muxer_opt or ["mplex/6.7.0"] - sec = sec_opt or ["secio"] + sec = sec_opt or {"insecure/1.0.0": InsecureTransport("insecure")} upgrader = TransportUpgrader(sec, muxer) peerstore = peerstore_opt or PeerStore() diff --git a/libp2p/encryption/secio.py b/libp2p/encryption/secio.py deleted file mode 100644 index e69de29b..00000000 diff --git a/libp2p/network/connection/raw_connection.py b/libp2p/network/connection/raw_connection.py index 1700469f..23cb5516 100644 --- a/libp2p/network/connection/raw_connection.py +++ b/libp2p/network/connection/raw_connection.py @@ -1,6 +1,5 @@ from .raw_connection_interface import IRawConnection - class RawConnection(IRawConnection): def __init__(self, ip, port, reader, writer, initiator): @@ -12,6 +11,19 @@ class RawConnection(IRawConnection): self._next_id = 0 if initiator else 1 self.initiator = initiator + async def write(self, data): + self.writer.write(data) + self.writer.write("\n".encode()) + await self.writer.drain() + + async def read(self): + line = await self.reader.readline() + adjusted_line = line.decode().rstrip('\n') + + # TODO: figure out a way to remove \n without going back and forth with + # encoding and decoding + return adjusted_line.encode() + def close(self): self.writer.close() diff --git a/libp2p/network/swarm.py b/libp2p/network/swarm.py index 9b37b50a..7d059915 100644 --- a/libp2p/network/swarm.py +++ b/libp2p/network/swarm.py @@ -72,8 +72,10 @@ class Swarm(INetwork): # Transport dials peer (gets back a raw conn) raw_conn = await self.transport.dial(multiaddr, self.self_id) - # Use upgrader to upgrade raw conn to muxed conn - muxed_conn = self.upgrader.upgrade_connection(raw_conn, \ + # Per, https://discuss.libp2p.io/t/multistream-security/130, we first secure + # the conn and then mux the conn + secured_conn = await self.upgrader.upgrade_security(raw_conn, peer_id, True) + muxed_conn = self.upgrader.upgrade_connection(secured_conn, \ self.generic_protocol_handler, peer_id) # Store muxed connection in connections @@ -148,7 +150,11 @@ class Swarm(INetwork): # to appropriate stream handler (using multiaddr) raw_conn = RawConnection(multiaddr.value_for_protocol('ip4'), multiaddr.value_for_protocol('tcp'), reader, writer, False) - muxed_conn = self.upgrader.upgrade_connection(raw_conn, \ + + # Per, https://discuss.libp2p.io/t/multistream-security/130, we first secure + # the conn and then mux the conn + secured_conn = await self.upgrader.upgrade_security(raw_conn, peer_id, False) + muxed_conn = self.upgrader.upgrade_connection(secured_conn, \ self.generic_protocol_handler, peer_id) # Store muxed_conn with peer id diff --git a/libp2p/protocol_muxer/multiselect_client.py b/libp2p/protocol_muxer/multiselect_client.py index dec54938..b93de4fb 100644 --- a/libp2p/protocol_muxer/multiselect_client.py +++ b/libp2p/protocol_muxer/multiselect_client.py @@ -45,7 +45,6 @@ class MultiselectClient(IMultiselectClient): :param stream: stream to communicate with multiselect over :return: selected protocol """ - # Create a communicator to handle all communication across the stream communicator = MultiselectCommunicator(stream) diff --git a/libp2p/protocol_muxer/multiselect_communicator.py b/libp2p/protocol_muxer/multiselect_communicator.py index d7e0dd50..0c5b3fa0 100644 --- a/libp2p/protocol_muxer/multiselect_communicator.py +++ b/libp2p/protocol_muxer/multiselect_communicator.py @@ -8,19 +8,24 @@ class MultiselectCommunicator(IMultiselectCommunicator): which is necessary for them to work """ - def __init__(self, stream): - self.stream = stream + def __init__(self, reader_writer): + """ + MultistreamCommunicator expects a reader_writer object that has + an async read and an async write function (this could be a stream, + raw connection, or other object implementing those functions) + """ + self.reader_writer = reader_writer async def write(self, msg_str): """ - Write message to stream + Write message to reader_writer :param msg_str: message to write """ - await self.stream.write(msg_str.encode()) + await self.reader_writer.write(msg_str.encode()) async def read_stream_until_eof(self): """ - Reads message from stream until EOF + Reads message from reader_writer until EOF """ - read_str = (await self.stream.read()).decode() + read_str = (await self.reader_writer.read()).decode() return read_str diff --git a/libp2p/encryption/__init__.py b/libp2p/security/__init__.py similarity index 100% rename from libp2p/encryption/__init__.py rename to libp2p/security/__init__.py diff --git a/libp2p/security/insecure_security.py b/libp2p/security/insecure_security.py new file mode 100644 index 00000000..dfa80a7e --- /dev/null +++ b/libp2p/security/insecure_security.py @@ -0,0 +1,44 @@ +from libp2p.security.secure_transport_interface import ISecureTransport +from libp2p.security.secure_conn_interface import ISecureConn + +class InsecureTransport(ISecureTransport): + + def __init__(self, transport_id): + self.transport_id = transport_id + + async def secure_inbound(self, conn): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are not the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + insecure_conn = InsecureConn(conn, self.transport_id) + return insecure_conn + + async def secure_outbound(self, conn, peer_id): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + insecure_conn = InsecureConn(conn, self.transport_id) + return insecure_conn + +class InsecureConn(ISecureConn): + + def __init__(self, conn, conn_id): + self.conn = conn + self.details = {} + self.details["id"] = conn_id + + def get_conn(self): + """ + :return: connection object that has been made secure + """ + return self.conn + + def get_security_details(self): + """ + :return: map containing details about the connections security + """ + return self.details diff --git a/libp2p/security/secure_conn_interface.py b/libp2p/security/secure_conn_interface.py new file mode 100644 index 00000000..e8433a29 --- /dev/null +++ b/libp2p/security/secure_conn_interface.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + +# pylint: disable=W0105 + +""" +Represents a secured connection object, which includes a connection and details about the security +involved in the secured connection + +Relevant go repo: https://github.com/libp2p/go-conn-security/blob/master/interface.go +""" +class ISecureConn(ABC): + + @abstractmethod + def get_conn(self): + """ + :return: connection object that has been made secure + """ + + @abstractmethod + def get_security_details(self): + """ + :return: map containing details about the connections security + """ diff --git a/libp2p/security/secure_transport_interface.py b/libp2p/security/secure_transport_interface.py new file mode 100644 index 00000000..54ca8b17 --- /dev/null +++ b/libp2p/security/secure_transport_interface.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod + +# pylint: disable=W0105 + +""" +Transport that is used to secure a connection. This transport is +chosen by a security transport multistream module. + +Relevant go repo: https://github.com/libp2p/go-conn-security/blob/master/interface.go +""" +class ISecureTransport(ABC): + + @abstractmethod + async def secure_inbound(self, conn): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are not the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + + @abstractmethod + async def secure_outbound(self, conn, peer_id): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ diff --git a/libp2p/security/security_multistream.py b/libp2p/security/security_multistream.py new file mode 100644 index 00000000..c8d2f884 --- /dev/null +++ b/libp2p/security/security_multistream.py @@ -0,0 +1,88 @@ +from abc import ABC +from libp2p.protocol_muxer.multiselect_client import MultiselectClient +from libp2p.protocol_muxer.multiselect import Multiselect + +# pylint: disable=W0105 + +""" +Represents a secured connection object, which includes a connection and details about the security +involved in the secured connection + +Relevant go repo: https://github.com/libp2p/go-conn-security/blob/master/interface.go +""" +class SecurityMultistream(ABC): + + def __init__(self): + # Map protocol to secure transport + self.transports = {} + + # Create multiselect + self.multiselect = Multiselect() + + # Create multiselect client + self.multiselect_client = MultiselectClient() + + def add_transport(self, protocol, transport): + # Associate protocol with transport + self.transports[protocol] = transport + + # Add protocol and handler to multiselect + # Note: None is added as the handler for the given protocol since + # we only care about selecting the protocol, not any handler function + self.multiselect.add_handler(protocol, None) + + + async def secure_inbound(self, conn): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are not the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + + # Select a secure transport + transport = await self.select_transport(conn, False) + + # Create secured connection + secure_conn = await transport.secure_inbound(conn) + + return secure_conn + + + async def secure_outbound(self, conn, peer_id): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + + # Select a secure transport + transport = await self.select_transport(conn, True) + + # Create secured connection + secure_conn = await transport.secure_outbound(conn, peer_id) + + return secure_conn + + + async def select_transport(self, conn, initiator): + """ + Select a transport that both us and the node on the + other end of conn support and agree on + :param conn: conn to choose a transport over + :param initiator: true if we are the initiator, false otherwise + :return: selected secure transport + """ + # TODO: Is conn acceptable to multiselect/multiselect_client + # instead of stream? In go repo, they pass in a raw conn + # (https://raw.githubusercontent.com/libp2p/go-conn-security-multistream/master/ssms.go) + + protocol = None + if initiator: + # Select protocol if initiator + protocol = \ + await self.multiselect_client.select_one_of(list(self.transports.keys()), conn) + else: + # Select protocol if non-initiator + protocol, _ = await self.multiselect.negotiate(conn) + # Return transport from protocol + return self.transports[protocol] diff --git a/libp2p/security/simple_security.py b/libp2p/security/simple_security.py new file mode 100644 index 00000000..62f56668 --- /dev/null +++ b/libp2p/security/simple_security.py @@ -0,0 +1,61 @@ +import asyncio +from libp2p.security.secure_transport_interface import ISecureTransport +from libp2p.security.secure_conn_interface import ISecureConn + +class SimpleSecurityTransport(ISecureTransport): + + def __init__(self, key_phrase): + self.key_phrase = key_phrase + + async def secure_inbound(self, conn): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are not the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + await conn.write(self.key_phrase.encode()) + incoming = (await conn.read()).decode() + + if incoming != self.key_phrase: + raise Exception("Key phrase differed between nodes. Expected " + self.key_phrase) + + secure_conn = SimpleSecureConn(conn, self.key_phrase) + return secure_conn + + async def secure_outbound(self, conn, peer_id): + """ + Secure the connection, either locally or by communicating with opposing node via conn, + for an inbound connection (i.e. we are the initiator) + :return: secure connection object (that implements secure_conn_interface) + """ + await conn.write(self.key_phrase.encode()) + incoming = (await conn.read()).decode() + + # Force context switch, as this security transport is built for testing locally + # in a single event loop + await asyncio.sleep(0) + + if incoming != self.key_phrase: + raise Exception("Key phrase differed between nodes. Expected " + self.key_phrase) + + secure_conn = SimpleSecureConn(conn, self.key_phrase) + return secure_conn + +class SimpleSecureConn(ISecureConn): + + def __init__(self, conn, key_phrase): + self.conn = conn + self.details = {} + self.details["key_phrase"] = key_phrase + + def get_conn(self): + """ + :return: connection object that has been made secure + """ + return self.conn + + def get_security_details(self): + """ + :return: map containing details about the connections security + """ + return self.details diff --git a/libp2p/stream_muxer/mplex/mplex.py b/libp2p/stream_muxer/mplex/mplex.py index 0d587b54..e660a524 100644 --- a/libp2p/stream_muxer/mplex/mplex.py +++ b/libp2p/stream_muxer/mplex/mplex.py @@ -11,7 +11,7 @@ class Mplex(IMuxedConn): reference: https://github.com/libp2p/go-mplex/blob/master/multiplex.go """ - def __init__(self, conn, generic_protocol_handler, peer_id): + def __init__(self, secured_conn, generic_protocol_handler, peer_id): """ create a new muxed connection :param conn: an instance of raw connection @@ -19,10 +19,11 @@ class Mplex(IMuxedConn): for new muxed streams :param peer_id: peer_id of peer the connection is to """ - super(Mplex, self).__init__(conn, generic_protocol_handler, peer_id) + super(Mplex, self).__init__(secured_conn, generic_protocol_handler, peer_id) - self.raw_conn = conn - self.initiator = conn.initiator + self.secured_conn = secured_conn + self.raw_conn = secured_conn.get_conn() + self.initiator = self.raw_conn.initiator # Store generic protocol handler self.generic_protocol_handler = generic_protocol_handler diff --git a/libp2p/stream_muxer/muxed_connection_interface.py b/libp2p/stream_muxer/muxed_connection_interface.py index 541fd64a..0faf7705 100644 --- a/libp2p/stream_muxer/muxed_connection_interface.py +++ b/libp2p/stream_muxer/muxed_connection_interface.py @@ -10,7 +10,7 @@ class IMuxedConn(ABC): def __init__(self, conn, generic_protocol_handler, peer_id): """ create a new muxed connection - :param conn: an instance of raw connection + :param conn: an instance of secured connection :param generic_protocol_handler: generic protocol handler for new muxed streams :param peer_id: peer_id of peer the connection is to diff --git a/libp2p/transport/tcp/tcp.py b/libp2p/transport/tcp/tcp.py index 21c0574b..f6167cee 100644 --- a/libp2p/transport/tcp/tcp.py +++ b/libp2p/transport/tcp/tcp.py @@ -82,9 +82,10 @@ class TCP(ITransport): await writer.drain() # Await ack for peer id - ack = (await reader.read(1024)).decode() + expected_ack_str = "received peer id" + ack = (await reader.read(len(expected_ack_str))).decode() - if ack != "received peer id": + if ack != expected_ack_str: raise Exception("Receiver did not receive peer id") return RawConnection(host, port, reader, writer, True) diff --git a/libp2p/transport/upgrader.py b/libp2p/transport/upgrader.py index 9e311e37..2ccce244 100644 --- a/libp2p/transport/upgrader.py +++ b/libp2p/transport/upgrader.py @@ -1,25 +1,36 @@ from libp2p.stream_muxer.mplex.mplex import Mplex +from libp2p.security.security_multistream import SecurityMultistream class TransportUpgrader: # pylint: disable=no-self-use def __init__(self, secOpt, muxerOpt): - self.sec = secOpt + # Store security option + self.security_multistream = SecurityMultistream() + for key in secOpt: + self.security_multistream.add_transport(key, secOpt[key]) + + # Store muxer option self.muxer = muxerOpt def upgrade_listener(self, transport, listeners): """ - upgrade multiaddr listeners to libp2p-transport listeners - + Upgrade multiaddr listeners to libp2p-transport listeners """ - def upgrade_security(self): - pass + async def upgrade_security(self, raw_conn, peer_id, initiator): + """ + Upgrade conn to be a secured connection + """ + if initiator: + return await self.security_multistream.secure_outbound(raw_conn, peer_id) + + return await self.security_multistream.secure_inbound(raw_conn) def upgrade_connection(self, conn, generic_protocol_handler, peer_id): """ - upgrade raw connection to muxed connection + Upgrade raw connection to muxed connection """ # For PoC, no security, default to mplex diff --git a/tests/security/test_security_multistream.py b/tests/security/test_security_multistream.py new file mode 100644 index 00000000..ed1ad299 --- /dev/null +++ b/tests/security/test_security_multistream.py @@ -0,0 +1,159 @@ +import asyncio +import multiaddr +import pytest + +from libp2p import new_node +from libp2p.peer.peerinfo import info_from_p2p_addr +from libp2p.protocol_muxer.multiselect_client import MultiselectClientError +from libp2p.security.insecure_security import InsecureTransport +from libp2p.security.simple_security import SimpleSecurityTransport +from tests.utils import cleanup + +# TODO: Add tests for multiple streams being opened on different +# protocols through the same connection + +def peer_id_for_node(node): + addr = node.get_addrs()[0] + info = info_from_p2p_addr(addr) + return info.peer_id + +async def connect(node1, node2): + """ + Connect node1 to node2 + """ + addr = node2.get_addrs()[0] + info = info_from_p2p_addr(addr) + await node1.connect(info) + +async def perform_simple_test(assertion_func, \ + transports_for_initiator, transports_for_noninitiator): + + # Create libp2p nodes and connect them, then secure the connection, then check + # the proper security was chosen + # TODO: implement -- note we need to introduce the notion of communicating over a raw connection + # for testing, we do NOT want to communicate over a stream so we can't just create two nodes + # and use their conn because our mplex will internally relay messages to a stream + sec_opt1 = transports_for_initiator + sec_opt2 = transports_for_noninitiator + + node1 = await new_node(transport_opt=["/ip4/127.0.0.1/tcp/0"], sec_opt=sec_opt1) + node2 = await new_node(transport_opt=["/ip4/127.0.0.1/tcp/0"], sec_opt=sec_opt2) + + await node1.get_network().listen(multiaddr.Multiaddr("/ip4/127.0.0.1/tcp/0")) + await node2.get_network().listen(multiaddr.Multiaddr("/ip4/127.0.0.1/tcp/0")) + + await connect(node1, node2) + + # Wait a very short period to allow conns to be stored (since the functions + # storing the conns are async, they may happen at slightly different times + # on each node) + await asyncio.sleep(0.1) + + # Get conns + node1_conn = node1.get_network().connections[peer_id_for_node(node2)] + node2_conn = node2.get_network().connections[peer_id_for_node(node1)] + + # Perform assertion + assertion_func(node1_conn.secured_conn.get_security_details()) + assertion_func(node2_conn.secured_conn.get_security_details()) + + # Success, terminate pending tasks. + await cleanup() + + +@pytest.mark.asyncio +async def test_single_insecure_security_transport_succeeds(): + transports_for_initiator = {"foo": InsecureTransport("foo")} + transports_for_noninitiator = {"foo": InsecureTransport("foo")} + + def assertion_func(details): + assert details["id"] == "foo" + + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator) + +@pytest.mark.asyncio +async def test_single_simple_test_security_transport_succeeds(): + transports_for_initiator = {"tacos": SimpleSecurityTransport("tacos")} + transports_for_noninitiator = {"tacos": SimpleSecurityTransport("tacos")} + + def assertion_func(details): + assert details["key_phrase"] == "tacos" + + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator) + +@pytest.mark.asyncio +async def test_two_simple_test_security_transport_for_initiator_succeeds(): + transports_for_initiator = {"tacos": SimpleSecurityTransport("tacos"), + "shleep": SimpleSecurityTransport("shleep")} + transports_for_noninitiator = {"shleep": SimpleSecurityTransport("shleep")} + + def assertion_func(details): + assert details["key_phrase"] == "shleep" + + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator) + +@pytest.mark.asyncio +async def test_two_simple_test_security_transport_for_noninitiator_succeeds(): + transports_for_initiator = {"tacos": SimpleSecurityTransport("tacos")} + transports_for_noninitiator = {"shleep": SimpleSecurityTransport("shleep"), + "tacos": SimpleSecurityTransport("tacos")} + + def assertion_func(details): + assert details["key_phrase"] == "tacos" + + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator) + + +@pytest.mark.asyncio +async def test_two_simple_test_security_transport_for_both_succeeds(): + transports_for_initiator = {"a": SimpleSecurityTransport("a"), + "b": SimpleSecurityTransport("b")} + transports_for_noninitiator = {"c": SimpleSecurityTransport("c"), + "b": SimpleSecurityTransport("b")} + + def assertion_func(details): + assert details["key_phrase"] == "b" + + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator) + +@pytest.mark.asyncio +async def test_multiple_security_none_the_same_fails(): + transports_for_initiator = {"a": SimpleSecurityTransport("a"), + "b": SimpleSecurityTransport("b")} + transports_for_noninitiator = {"c": SimpleSecurityTransport("c"), + "d": SimpleSecurityTransport("d")} + + def assertion_func(_): + assert False + + with pytest.raises(MultiselectClientError): + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator) + + await cleanup() + +@pytest.mark.asyncio +async def test_default_insecure_security(): + transports_for_initiator = None + transports_for_noninitiator = None + + details1 = None + details2 = None + + def assertion_func(details): + nonlocal details1 + nonlocal details2 + if not details1: + details1 = details + elif not details2: + details2 = details + else: + assert details1 == details2 + + await perform_simple_test(assertion_func, + transports_for_initiator, transports_for_noninitiator)