From 9f58a72d4de9766496fc6a5e919af9e018cd5440 Mon Sep 17 00:00:00 2001 From: Youngjoon Lee <5462944+youngjoon-lee@users.noreply.github.com> Date: Wed, 26 Jun 2024 15:55:00 +0900 Subject: [PATCH] mixnet v2 --- mixnet/README.md | 21 ---- mixnet/bls.py | 13 --- mixnet/client.py | 118 ---------------------- mixnet/config.py | 100 +++++------------- mixnet/fisheryates.py | 21 ---- mixnet/mixnet.py | 62 ------------ mixnet/node.py | 202 ++++++++++++++++++++++--------------- mixnet/packet.py | 24 ++--- mixnet/poisson.py | 13 --- mixnet/structure.png | Bin 43180 -> 0 bytes mixnet/test_client.py | 45 --------- mixnet/test_fisheryates.py | 21 ---- mixnet/test_mixnet.py | 20 ---- mixnet/test_node.py | 138 ++++++------------------- mixnet/test_packet.py | 25 +++-- mixnet/test_utils.py | 50 +++------ mixnet/utils.py | 6 -- 17 files changed, 209 insertions(+), 670 deletions(-) delete mode 100644 mixnet/README.md delete mode 100644 mixnet/bls.py delete mode 100644 mixnet/client.py delete mode 100644 mixnet/fisheryates.py delete mode 100644 mixnet/mixnet.py delete mode 100644 mixnet/poisson.py delete mode 100644 mixnet/structure.png delete mode 100644 mixnet/test_client.py delete mode 100644 mixnet/test_fisheryates.py delete mode 100644 mixnet/test_mixnet.py delete mode 100644 mixnet/utils.py diff --git a/mixnet/README.md b/mixnet/README.md deleted file mode 100644 index 0cc18d93..00000000 --- a/mixnet/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Mixnet Specification - -This is the executable specification of Mixnet, which can be used as a networking layer of the Nomos network. - -![](structure.png) - -## Public Components - -- [`mixnet.py`](mixnet.py): A public interface of the Mixnet layer, which can be used by upper layers -- [`robustness.py`](robustness.py): A public interface of the Robustness layer, which can be on top of the Mixnet layer and used by upper layers - -## Private Components - -There are two primary components in the Mixnet layer. - -- [`client.py`](client.py): A mix client interface, which splits a message into Sphinx packets, sends packets to mix nodes, and receives messages via gossip. Also, this emits cover packets periodically. -- [`node.py`](node.py): A mix node interface, which receives Sphinx packets from other mix nodes, processes packets, and forwards packets to other mix nodes. This works only when selected by the topology construction. - -Each component receives a new topology from the Robustness layer. - -There is no interaction between mix client and mix node components. diff --git a/mixnet/bls.py b/mixnet/bls.py deleted file mode 100644 index a4278b08..00000000 --- a/mixnet/bls.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import TypeAlias - -import blspy - -from mixnet.utils import random_bytes - -BlsPrivateKey: TypeAlias = blspy.PrivateKey -BlsPublicKey: TypeAlias = blspy.G1Element - - -def generate_bls() -> BlsPrivateKey: - seed = random_bytes(32) - return blspy.BasicSchemeMPL.key_gen(seed) diff --git a/mixnet/client.py b/mixnet/client.py deleted file mode 100644 index 8d21cf3c..00000000 --- a/mixnet/client.py +++ /dev/null @@ -1,118 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import suppress -from typing import Self - -from mixnet.config import MixClientConfig, MixnetTopology -from mixnet.node import PacketQueue -from mixnet.packet import PacketBuilder -from mixnet.poisson import poisson_interval_sec - - -class MixClient: - config: MixClientConfig - real_packet_queue: PacketQueue - outbound_socket: PacketQueue - task: asyncio.Task # A reference just to prevent task from being garbage collected - - @classmethod - async def new( - cls, - config: MixClientConfig, - ) -> Self: - self = cls() - self.config = config - self.real_packet_queue = asyncio.Queue() - self.outbound_socket = asyncio.Queue() - self.task = asyncio.create_task(self.__run()) - return self - - def set_topology(self, topology: MixnetTopology) -> None: - """ - Replace the old topology with the new topology received - - In real implementations, this method may be integrated in a long-running task. - Here in the spec, this method has been simplified as a setter, assuming the single-thread test environment. - """ - self.config.topology = topology - - # Only for testing - def get_topology(self) -> MixnetTopology: - return self.config.topology - - async def send_message(self, msg: bytes) -> None: - packets_and_routes = PacketBuilder.build_real_packets(msg, self.config.topology) - for packet, route in packets_and_routes: - await self.real_packet_queue.put((route[0].addr, packet)) - - def subscribe_messages(self) -> "asyncio.Queue[bytes]": - """ - Subscribe messages, which went through mix nodes and were broadcasted via gossip - """ - return asyncio.Queue() - - async def __run(self): - """ - Emit packets at the Poisson emission_rate_per_min. - - If a real packet is scheduled to be sent, this thread sends the real packet to the mixnet, - and schedules redundant real packets to be emitted in the next turns. - - If no real packet is not scheduled, this thread emits a cover packet according to the emission_rate_per_min. - """ - - redundant_real_packet_queue: PacketQueue = asyncio.Queue() - - emission_notifier_queue = asyncio.Queue() - _ = asyncio.create_task( - self.__emission_notifier( - self.config.emission_rate_per_min, emission_notifier_queue - ) - ) - - while True: - # Wait until the next emission time - _ = await emission_notifier_queue.get() - try: - await self.__emit(self.config.redundancy, redundant_real_packet_queue) - finally: - # Python convention: indicate that the previously enqueued task has been processed - emission_notifier_queue.task_done() - - async def __emit( - self, - redundancy: int, # b in the spec - redundant_real_packet_queue: PacketQueue, - ): - if not redundant_real_packet_queue.empty(): - addr, packet = redundant_real_packet_queue.get_nowait() - await self.outbound_socket.put((addr, packet)) - return - - if not self.real_packet_queue.empty(): - addr, packet = self.real_packet_queue.get_nowait() - # Schedule redundant real packets - for _ in range(redundancy - 1): - redundant_real_packet_queue.put_nowait((addr, packet)) - await self.outbound_socket.put((addr, packet)) - - packets_and_routes = PacketBuilder.build_drop_cover_packets( - b"drop cover", self.config.topology - ) - # We have a for loop here, but we expect that the total num of packets is 1 - # because the dummy message is short. - for packet, route in packets_and_routes: - await self.outbound_socket.put((route[0].addr, packet)) - - async def __emission_notifier( - self, emission_rate_per_min: int, queue: asyncio.Queue - ): - while True: - await asyncio.sleep(poisson_interval_sec(emission_rate_per_min)) - queue.put_nowait(None) - - async def cancel(self) -> None: - self.task.cancel() - with suppress(asyncio.CancelledError): - await self.task diff --git a/mixnet/config.py b/mixnet/config.py index 5b1a2d10..e09983d7 100644 --- a/mixnet/config.py +++ b/mixnet/config.py @@ -2,110 +2,56 @@ import random from dataclasses import dataclass -from typing import List, TypeAlias +from typing import List from cryptography.hazmat.primitives.asymmetric.x25519 import ( X25519PrivateKey, X25519PublicKey, ) -from pysphinx.node import Node - -from mixnet.bls import BlsPrivateKey, BlsPublicKey -from mixnet.fisheryates import FisherYates +from pysphinx.sphinx import Node as SphinxNode @dataclass class MixnetConfig: - topology_config: MixnetTopologyConfig - mixclient_config: MixClientConfig - mixnode_config: MixNodeConfig + node_configs: List[NodeConfig] + membership: MixMembership @dataclass -class MixnetTopologyConfig: - mixnode_candidates: List[MixNodeInfo] - size: MixnetTopologySize - entropy: bytes +class NodeConfig: + private_key: X25519PrivateKey + transmission_rate_per_sec: int # Global Transmission Rate @dataclass -class MixClientConfig: - emission_rate_per_min: int # Poisson rate parameter: lambda - redundancy: int - topology: MixnetTopology - - -@dataclass -class MixNodeConfig: - encryption_private_key: X25519PrivateKey - delay_rate_per_min: int # Poisson rate parameter: mu - - -@dataclass -class MixnetTopology: - # In production, this can be a 1-D array, which is accessible by indexes. - # Here, we use a 2-D array for readability. - layers: List[List[MixNodeInfo]] - - def __init__( - self, - config: MixnetTopologyConfig, - ) -> None: - """ - Build a new topology deterministically using an entropy and a given set of candidates. - """ - shuffled = FisherYates.shuffle(config.mixnode_candidates, config.entropy) - sampled = shuffled[: config.size.num_total_mixnodes()] - - layers = [] - for layer_id in range(config.size.num_layers): - start = layer_id * config.size.num_mixnodes_per_layer - layer = sampled[start : start + config.size.num_mixnodes_per_layer] - layers.append(layer) - self.layers = layers +class MixMembership: + nodes: List[NodeInfo] - def generate_route(self, mix_destination: MixNodeInfo) -> list[MixNodeInfo]: + def generate_route(self, num_hops: int, last_mix: NodeInfo) -> list[NodeInfo]: """ Generate a mix route for a Sphinx packet. The pre-selected mix_destination is used as a last mix node in the route, so that associated packets can be merged together into a original message. """ - route = [random.choice(layer) for layer in self.layers[:-1]] - route.append(mix_destination) + route = [self.choose() for _ in range(num_hops - 1)] + route.append(last_mix) return route - def choose_mix_destination(self) -> MixNodeInfo: + def choose(self) -> NodeInfo: """ - Choose a mix node from the last mix layer as a mix destination - that will reconstruct a message from Sphinx packets. + Choose a mix node as a mix destination that will reconstruct a message from Sphinx packets. """ - return random.choice(self.layers[-1]) + return random.choice(self.nodes) @dataclass -class MixnetTopologySize: - num_layers: int - num_mixnodes_per_layer: int - - def num_total_mixnodes(self) -> int: - return self.num_layers * self.num_mixnodes_per_layer - - -# 32-byte that represents an IP address and a port of a mix node. -NodeAddress: TypeAlias = bytes - - -@dataclass -class MixNodeInfo: - identity_private_key: BlsPrivateKey - encryption_private_key: X25519PrivateKey - addr: NodeAddress - - def identity_public_key(self) -> BlsPublicKey: - return self.identity_private_key.get_g1() +class NodeInfo: + private_key: X25519PrivateKey - def encryption_public_key(self) -> X25519PublicKey: - return self.encryption_private_key.public_key() + def public_key(self) -> X25519PublicKey: + return self.private_key.public_key() - def sphinx_node(self) -> Node: - return Node(self.encryption_private_key, self.addr) + def sphinx_node(self) -> SphinxNode: + # TODO: Use a pre-signed incentive tx, instead of NodeAddress + dummy_node_addr = bytes(32) + return SphinxNode(self.private_key, dummy_node_addr) diff --git a/mixnet/fisheryates.py b/mixnet/fisheryates.py deleted file mode 100644 index 70c92ffc..00000000 --- a/mixnet/fisheryates.py +++ /dev/null @@ -1,21 +0,0 @@ -import random -from typing import List - - -class FisherYates: - @staticmethod - def shuffle(elements: List, entropy: bytes) -> List: - """ - Fisher-Yates shuffling algorithm. - In Python, random.shuffle implements the Fisher-Yates shuffling. - https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle - https://softwareengineering.stackexchange.com/a/215780 - :param elements: elements to be shuffled - :param entropy: a seed for deterministic sampling - """ - out = elements.copy() - random.seed(a=entropy, version=2) - random.shuffle(out) - # reset seed - random.seed() - return out diff --git a/mixnet/mixnet.py b/mixnet/mixnet.py deleted file mode 100644 index dedc9efd..00000000 --- a/mixnet/mixnet.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import suppress -from typing import Self, TypeAlias - -from mixnet.client import MixClient -from mixnet.config import MixnetConfig, MixnetTopology, MixnetTopologyConfig -from mixnet.node import MixNode - -EntropyQueue: TypeAlias = "asyncio.Queue[bytes]" - - -class Mixnet: - topology_config: MixnetTopologyConfig - - mixclient: MixClient - mixnode: MixNode - entropy_queue: EntropyQueue - task: asyncio.Task # A reference just to prevent task from being garbage collected - - @classmethod - async def new( - cls, - config: MixnetConfig, - entropy_queue: EntropyQueue, - ) -> Self: - self = cls() - self.topology_config = config.topology_config - self.mixclient = await MixClient.new(config.mixclient_config) - self.mixnode = await MixNode.new(config.mixnode_config) - self.entropy_queue = entropy_queue - self.task = asyncio.create_task(self.__consume_entropy()) - return self - - async def publish_message(self, msg: bytes) -> None: - await self.mixclient.send_message(msg) - - def subscribe_messages(self) -> "asyncio.Queue[bytes]": - return self.mixclient.subscribe_messages() - - async def __consume_entropy( - self, - ) -> None: - while True: - entropy = await self.entropy_queue.get() - self.topology_config.entropy = entropy - - topology = MixnetTopology(self.topology_config) - self.mixclient.set_topology(topology) - - async def cancel(self) -> None: - self.task.cancel() - with suppress(asyncio.CancelledError): - await self.task - - await self.mixclient.cancel() - await self.mixnode.cancel() - - # Only for testing - def get_topology(self) -> MixnetTopology: - return self.mixclient.get_topology() diff --git a/mixnet/node.py b/mixnet/node.py index 8ab4b77c..4c931ef2 100644 --- a/mixnet/node.py +++ b/mixnet/node.py @@ -1,107 +1,141 @@ from __future__ import annotations import asyncio -from contextlib import suppress -from typing import Self, Tuple, TypeAlias +from typing import Awaitable, Callable, TypeAlias -from cryptography.hazmat.primitives.asymmetric.x25519 import ( - X25519PrivateKey, -) +from pysphinx.payload import DEFAULT_PAYLOAD_SIZE from pysphinx.sphinx import ( Payload, ProcessedFinalHopPacket, ProcessedForwardHopPacket, SphinxPacket, - UnknownHeaderTypeError, ) -from mixnet.config import MixNodeConfig, NodeAddress -from mixnet.poisson import poisson_interval_sec +from mixnet.config import MixMembership, NodeConfig +from mixnet.packet import Fragment, MessageFlag, MessageReconstructor, PacketBuilder -PacketQueue: TypeAlias = "asyncio.Queue[Tuple[NodeAddress, SphinxPacket]]" -PacketPayloadQueue: TypeAlias = ( - "asyncio.Queue[Tuple[NodeAddress, SphinxPacket | Payload]]" -) +NetworkPacket: TypeAlias = "SphinxPacket | bytes" +NetworkPacketQueue: TypeAlias = "asyncio.Queue[NetworkPacket]" +Connection: TypeAlias = NetworkPacketQueue +BroadcastChannel: TypeAlias = "asyncio.Queue[bytes]" -class MixNode: - """ - A class handling incoming packets with delays +class Node: + config: NodeConfig + membership: MixMembership + mixgossip_channel: MixGossipChannel + reconstructor: MessageReconstructor + broadcast_channel: BroadcastChannel - This class is defined separated with the MixNode class, - in order to define the MixNode as a simple dataclass for clarity. - """ + def __init__(self, config: NodeConfig, membership: MixMembership): + self.config = config + self.membership = membership + self.mixgossip_channel = MixGossipChannel(self.__process_sphinx_packet) + self.reconstructor = MessageReconstructor() + self.broadcast_channel = asyncio.Queue() + + async def __process_sphinx_packet( + self, packet: SphinxPacket + ) -> NetworkPacket | None: + try: + processed = packet.process(self.config.private_key) + match processed: + case ProcessedForwardHopPacket(): + return processed.next_packet + case ProcessedFinalHopPacket(): + await self.__process_sphinx_payload(processed.payload) + except Exception: + # Return SphinxPacket as it is, if this node cannot unwrap it. + return packet + + async def __process_sphinx_payload(self, payload: Payload): + msg_with_flag = self.reconstructor.add( + Fragment.from_bytes(payload.recover_plain_playload()) + ) + if msg_with_flag is not None: + flag, msg = PacketBuilder.parse_msg_and_flag(msg_with_flag) + if flag == MessageFlag.MESSAGE_FLAG_REAL: + await self.broadcast_channel.put(msg) + + def connect(self, peer: Node): + conn = asyncio.Queue() + peer.mixgossip_channel.add_inbound(conn) + self.mixgossip_channel.add_outbound( + MixOutboundConnection(conn, self.config.transmission_rate_per_sec) + ) + + async def send_message(self, msg: bytes): + for packet, _ in PacketBuilder.build_real_packets(msg, self.membership): + await self.mixgossip_channel.gossip(packet) + + +class MixGossipChannel: + inbound_conns: list[Connection] + outbound_conns: list[MixOutboundConnection] + handler: Callable[[SphinxPacket], Awaitable[NetworkPacket | None]] + + def __init__( + self, + handler: Callable[[SphinxPacket], Awaitable[NetworkPacket | None]], + ): + self.inbound_conns = [] + self.outbound_conns = [] + self.handler = handler + # A set just for gathering a reference of tasks to prevent them from being garbage collected. + # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task + self.tasks = set() - config: MixNodeConfig - inbound_socket: PacketQueue - outbound_socket: PacketPayloadQueue - task: asyncio.Task # A reference just to prevent task from being garbage collected + def add_inbound(self, conn: Connection): + self.inbound_conns.append(conn) + task = asyncio.create_task(self.__process_inbound_conn(conn)) + self.tasks.add(task) + # To discard the task from the set automatically when it is done. + task.add_done_callback(self.tasks.discard) - @classmethod - async def new( - cls, - config: MixNodeConfig, - ) -> Self: - self = cls() - self.config = config - self.inbound_socket = asyncio.Queue() - self.outbound_socket = asyncio.Queue() + def add_outbound(self, conn: MixOutboundConnection): + self.outbound_conns.append(conn) + + async def __process_inbound_conn(self, conn: Connection): + while True: + elem = await conn.get() + if isinstance(elem, bytes): + assert elem == build_noise_packet() + # Drop packet + continue + elif isinstance(elem, SphinxPacket): + net_packet = await self.handler(elem) + if net_packet is not None: + await self.gossip(net_packet) + + async def gossip(self, packet: NetworkPacket): + for conn in self.outbound_conns: + await conn.send(packet) + + +class MixOutboundConnection: + queue: NetworkPacketQueue + conn: Connection + transmission_rate_per_sec: int + + def __init__(self, conn: Connection, transmission_rate_per_sec: int): + self.queue = asyncio.Queue() + self.conn = conn + self.transmission_rate_per_sec = transmission_rate_per_sec self.task = asyncio.create_task(self.__run()) - return self async def __run(self): - """ - Read SphinxPackets from inbound socket and spawn a thread for each packet to process it. + while True: + await asyncio.sleep(1 / self.transmission_rate_per_sec) + # TODO: time mixing + if self.queue.empty(): + elem = build_noise_packet() + else: + elem = self.queue.get_nowait() + await self.conn.put(elem) - This thread approximates a M/M/inf queue. - """ + async def send(self, elem: NetworkPacket): + await self.queue.put(elem) - # A set just for gathering a reference of tasks to prevent them from being garbage collected. - # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task - self.tasks = set() - while True: - _, packet = await self.inbound_socket.get() - task = asyncio.create_task( - self.__process_packet( - packet, - self.config.encryption_private_key, - self.config.delay_rate_per_min, - ) - ) - self.tasks.add(task) - # To discard the task from the set automatically when it is done. - task.add_done_callback(self.tasks.discard) - - async def __process_packet( - self, - packet: SphinxPacket, - encryption_private_key: X25519PrivateKey, - delay_rate_per_min: int, # Poisson rate parameter: mu - ): - """ - Process a single packet with a delay that follows exponential distribution, - and forward it to the next mix node or the mix destination - - This thread is a single server (worker) in a M/M/inf queue that MixNodeRunner approximates. - """ - delay_sec = poisson_interval_sec(delay_rate_per_min) - await asyncio.sleep(delay_sec) - - processed = packet.process(encryption_private_key) - match processed: - case ProcessedForwardHopPacket(): - await self.outbound_socket.put( - (processed.next_node_address, processed.next_packet) - ) - case ProcessedFinalHopPacket(): - await self.outbound_socket.put( - (processed.destination_node_address, processed.payload) - ) - case _: - raise UnknownHeaderTypeError - - async def cancel(self) -> None: - self.task.cancel() - with suppress(asyncio.CancelledError): - await self.task +def build_noise_packet() -> bytes: + return bytes(DEFAULT_PAYLOAD_SIZE) diff --git a/mixnet/packet.py b/mixnet/packet.py index 71fe6878..1ac833bf 100644 --- a/mixnet/packet.py +++ b/mixnet/packet.py @@ -9,7 +9,7 @@ from pysphinx.payload import Payload from pysphinx.sphinx import SphinxPacket -from mixnet.config import MixnetTopology, MixNodeInfo +from mixnet.config import MixMembership, NodeInfo class MessageFlag(Enum): @@ -23,25 +23,25 @@ def bytes(self) -> bytes: class PacketBuilder: @staticmethod def build_real_packets( - message: bytes, topology: MixnetTopology - ) -> List[Tuple[SphinxPacket, List[MixNodeInfo]]]: + message: bytes, membership: MixMembership + ) -> List[Tuple[SphinxPacket, List[NodeInfo]]]: return PacketBuilder.__build_packets( - MessageFlag.MESSAGE_FLAG_REAL, message, topology + MessageFlag.MESSAGE_FLAG_REAL, message, membership ) @staticmethod def build_drop_cover_packets( - message: bytes, topology: MixnetTopology - ) -> List[Tuple[SphinxPacket, List[MixNodeInfo]]]: + message: bytes, membership: MixMembership + ) -> List[Tuple[SphinxPacket, List[NodeInfo]]]: return PacketBuilder.__build_packets( - MessageFlag.MESSAGE_FLAG_DROP_COVER, message, topology + MessageFlag.MESSAGE_FLAG_DROP_COVER, message, membership ) @staticmethod def __build_packets( - flag: MessageFlag, message: bytes, topology: MixnetTopology - ) -> List[Tuple[SphinxPacket, List[MixNodeInfo]]]: - destination = topology.choose_mix_destination() + flag: MessageFlag, message: bytes, membership: MixMembership + ) -> List[Tuple[SphinxPacket, List[NodeInfo]]]: + last_mix = membership.choose() msg_with_flag = flag.bytes() + message # NOTE: We don't encrypt msg_with_flag for destination. @@ -50,11 +50,11 @@ def __build_packets( out = [] for fragment in fragment_set.fragments: - route = topology.generate_route(destination) + route = membership.generate_route(3, last_mix) packet = SphinxPacket.build( fragment.bytes(), [mixnode.sphinx_node() for mixnode in route], - destination.sphinx_node(), + last_mix.sphinx_node(), ) out.append((packet, route)) diff --git a/mixnet/poisson.py b/mixnet/poisson.py deleted file mode 100644 index 86a4b66a..00000000 --- a/mixnet/poisson.py +++ /dev/null @@ -1,13 +0,0 @@ -import numpy - - -def poisson_interval_sec(rate_per_min: int) -> float: - # If events occur in a Poisson distribution with rate_per_min, - # the interval between events follows the exponential distribution - # with the rate_per_min (i.e. with the scale 1/rate_per_min). - interval_min = numpy.random.exponential(scale=1 / rate_per_min, size=1)[0] - return interval_min * 60 - - -def poisson_mean_interval_sec(rate_per_min: int) -> float: - return 1 / rate_per_min * 60 diff --git a/mixnet/structure.png b/mixnet/structure.png deleted file mode 100644 index 994313c522a5afdefe957f72a720aaf7ab383cbe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43180 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 Nsi76F0(&g-e*j6Sb<+R< diff --git a/mixnet/test_client.py b/mixnet/test_client.py deleted file mode 100644 index 43274662..00000000 --- a/mixnet/test_client.py +++ /dev/null @@ -1,45 +0,0 @@ -from datetime import datetime -from unittest import IsolatedAsyncioTestCase - -import numpy - -from mixnet.client import MixClient -from mixnet.poisson import poisson_mean_interval_sec -from mixnet.test_utils import ( - init_mixnet_config, - with_test_timeout, -) -from mixnet.utils import random_bytes - - -class TestMixClient(IsolatedAsyncioTestCase): - @with_test_timeout(100) - async def test_mixclient(self): - config = init_mixnet_config().mixclient_config - config.emission_rate_per_min = 30 - config.redundancy = 3 - - mixclient = await MixClient.new(config) - try: - # Send a 3500-byte msg, expecting that it is split into at least two packets - await mixclient.send_message(random_bytes(3500)) - - # Calculate intervals between packet emissions from the mix client - intervals = [] - ts = datetime.now() - for _ in range(30): - _ = await mixclient.outbound_socket.get() - now = datetime.now() - intervals.append((now - ts).total_seconds()) - ts = now - - # Check if packets were emitted at the Poisson emission_rate - # If emissions follow the Poisson distribution with a rate `lambda`, - # a mean interval between emissions must be `1/lambda`. - self.assertAlmostEqual( - float(numpy.mean(intervals)), - poisson_mean_interval_sec(config.emission_rate_per_min), - delta=1.0, - ) - finally: - await mixclient.cancel() diff --git a/mixnet/test_fisheryates.py b/mixnet/test_fisheryates.py deleted file mode 100644 index a32554c6..00000000 --- a/mixnet/test_fisheryates.py +++ /dev/null @@ -1,21 +0,0 @@ -from unittest import TestCase - -from mixnet.fisheryates import FisherYates - - -class TestFisherYates(TestCase): - def test_shuffle(self): - entropy = b"hello" - elems = [1, 2, 3, 4, 5] - - shuffled1 = FisherYates.shuffle(elems, entropy) - self.assertEqual(sorted(elems), sorted(shuffled1)) - - # shuffle again with the same entropy - shuffled2 = FisherYates.shuffle(elems, entropy) - self.assertEqual(shuffled1, shuffled2) - - # shuffle with a different entropy - shuffled3 = FisherYates.shuffle(elems, b"world") - self.assertNotEqual(shuffled1, shuffled3) - self.assertEqual(sorted(elems), sorted(shuffled3)) diff --git a/mixnet/test_mixnet.py b/mixnet/test_mixnet.py deleted file mode 100644 index 9a0e4cb1..00000000 --- a/mixnet/test_mixnet.py +++ /dev/null @@ -1,20 +0,0 @@ -import asyncio -from unittest import IsolatedAsyncioTestCase - -from mixnet.mixnet import Mixnet -from mixnet.test_utils import init_mixnet_config - - -class TestMixnet(IsolatedAsyncioTestCase): - async def test_topology_from_robustness(self): - config = init_mixnet_config() - entropy_queue = asyncio.Queue() - - mixnet = await Mixnet.new(config, entropy_queue) - try: - old_topology = config.mixclient_config.topology - await entropy_queue.put(b"new entropy") - await asyncio.sleep(1) - self.assertNotEqual(old_topology, mixnet.get_topology()) - finally: - await mixnet.cancel() diff --git a/mixnet/test_node.py b/mixnet/test_node.py index 26ab0c7b..89ddf6aa 100644 --- a/mixnet/test_node.py +++ b/mixnet/test_node.py @@ -1,117 +1,37 @@ import asyncio -from datetime import datetime from unittest import IsolatedAsyncioTestCase -import numpy -from pysphinx.sphinx import SphinxPacket - -from mixnet.node import MixNode, NodeAddress, PacketQueue -from mixnet.packet import PacketBuilder -from mixnet.poisson import poisson_interval_sec, poisson_mean_interval_sec +from mixnet.node import Node from mixnet.test_utils import ( init_mixnet_config, - with_test_timeout, ) -class TestMixNodeRunner(IsolatedAsyncioTestCase): - @with_test_timeout(180) - async def test_mixnode_emission_rate(self): - """ - Test if MixNodeRunner works as a M/M/inf queue. - - If inputs are arrived at Poisson rate `lambda`, - and if processing is delayed according to an exponential distribution with a rate `mu`, - the rate of outputs should be `lambda`. - """ - config = init_mixnet_config() - config.mixclient_config.emission_rate_per_min = 120 # lambda (= 2msg/sec) - config.mixnode_config.delay_rate_per_min = 30 # mu (= 2s delay on average) - - packet, route = PacketBuilder.build_real_packets( - b"msg", config.mixclient_config.topology - )[0] - - # Start only the first mix node for testing - config.mixnode_config.encryption_private_key = route[0].encryption_private_key - mixnode = await MixNode.new(config.mixnode_config) - try: - # Send packets to the first mix node in a Poisson distribution - packet_count = 100 - # This queue is just for counting how many packets have been sent so far. - sent_packet_queue: PacketQueue = asyncio.Queue() - sender_task = asyncio.create_task( - self.send_packets( - mixnode.inbound_socket, - packet, - route[0].addr, - packet_count, - config.mixclient_config.emission_rate_per_min, - sent_packet_queue, - ) - ) - try: - # Calculate intervals between outputs and gather num_jobs in the first mix node. - intervals = [] - num_jobs = [] - ts = datetime.now() - for _ in range(packet_count): - _ = await mixnode.outbound_socket.get() - now = datetime.now() - intervals.append((now - ts).total_seconds()) - - # Calculate the current # of jobs staying in the mix node - num_packets_emitted_from_mixnode = len(intervals) - num_packets_sent_to_mixnode = sent_packet_queue.qsize() - num_jobs.append( - num_packets_sent_to_mixnode - num_packets_emitted_from_mixnode - ) - - ts = now - - # Remove the first interval that would be much larger than other intervals, - # because of the delay in mix node. - intervals = intervals[1:] - num_jobs = num_jobs[1:] - - # Check if the emission rate of the first mix node is the same as - # the emission rate of the message sender, but with a delay. - # If outputs follow the Poisson distribution with a rate `lambda`, - # a mean interval between outputs must be `1/lambda`. - self.assertAlmostEqual( - float(numpy.mean(intervals)), - poisson_mean_interval_sec( - config.mixclient_config.emission_rate_per_min - ), - delta=1.0, - ) - # If runner is a M/M/inf queue, - # a mean number of jobs being processed/scheduled in the runner must be `lambda/mu`. - self.assertAlmostEqual( - float(numpy.mean(num_jobs)), - round( - config.mixclient_config.emission_rate_per_min - / config.mixnode_config.delay_rate_per_min - ), - delta=1.5, - ) - finally: - await sender_task - finally: - await mixnode.cancel() - - @staticmethod - async def send_packets( - inbound_socket: PacketQueue, - packet: SphinxPacket, - node_addr: NodeAddress, - cnt: int, - rate_per_min: int, - # For testing purpose, to inform the caller how many packets have been sent to the inbound_socket - sent_packet_queue: PacketQueue, - ): - for _ in range(cnt): - # Since the task is not heavy, just sleep for seconds instead of using emission_notifier - await asyncio.sleep(poisson_interval_sec(rate_per_min)) - await inbound_socket.put((node_addr, packet)) - await sent_packet_queue.put((node_addr, packet)) +class TestNode(IsolatedAsyncioTestCase): + async def test_node(self): + config = init_mixnet_config(10) + nodes = [ + Node(node_config, config.membership) for node_config in config.node_configs + ] + for i, node in enumerate(nodes): + node.connect(nodes[(i + 1) % len(nodes)]) + + await nodes[0].send_message(b"block selection") + + timeout = 15 + for _ in range(timeout): + broadcasted_msgs = [] + for node in nodes: + if not node.broadcast_channel.empty(): + broadcasted_msgs.append(node.broadcast_channel.get_nowait()) + + if len(broadcasted_msgs) == 0: + await asyncio.sleep(1) + else: + # We expect only one node to broadcast the message. + assert len(broadcasted_msgs) == 1 + self.assertEqual(b"block selection", broadcasted_msgs[0]) + return + self.fail("timeout") + + # TODO: check noise diff --git a/mixnet/test_packet.py b/mixnet/test_packet.py index d1d517a6..81e3a259 100644 --- a/mixnet/test_packet.py +++ b/mixnet/test_packet.py @@ -1,9 +1,10 @@ +from random import randint from typing import List from unittest import TestCase from pysphinx.sphinx import ProcessedFinalHopPacket, SphinxPacket -from mixnet.config import MixNodeInfo +from mixnet.config import NodeInfo from mixnet.packet import ( Fragment, MessageFlag, @@ -11,14 +12,13 @@ PacketBuilder, ) from mixnet.test_utils import init_mixnet_config -from mixnet.utils import random_bytes class TestPacket(TestCase): def test_real_packet(self): - topology = init_mixnet_config().mixclient_config.topology - msg = random_bytes(3500) - packets_and_routes = PacketBuilder.build_real_packets(msg, topology) + membership = init_mixnet_config(10).membership + msg = self.random_bytes(3500) + packets_and_routes = PacketBuilder.build_real_packets(msg, membership) self.assertEqual(4, len(packets_and_routes)) reconstructor = MessageReconstructor() @@ -47,9 +47,9 @@ def test_real_packet(self): ) def test_cover_packet(self): - topology = init_mixnet_config().mixclient_config.topology + membership = init_mixnet_config(10).membership msg = b"cover" - packets_and_routes = PacketBuilder.build_drop_cover_packets(msg, topology) + packets_and_routes = PacketBuilder.build_drop_cover_packets(msg, membership) self.assertEqual(1, len(packets_and_routes)) reconstructor = MessageReconstructor() @@ -63,16 +63,21 @@ def test_cover_packet(self): ) @staticmethod - def process_packet(packet: SphinxPacket, route: List[MixNodeInfo]) -> Fragment: - processed = packet.process(route[0].encryption_private_key) + def process_packet(packet: SphinxPacket, route: List[NodeInfo]) -> Fragment: + processed = packet.process(route[0].private_key) if isinstance(processed, ProcessedFinalHopPacket): return Fragment.from_bytes(processed.payload.recover_plain_playload()) else: processed = processed for node in route[1:]: - p = processed.next_packet.process(node.encryption_private_key) + p = processed.next_packet.process(node.private_key) if isinstance(p, ProcessedFinalHopPacket): return Fragment.from_bytes(p.payload.recover_plain_playload()) else: processed = p assert False + + @staticmethod + def random_bytes(size: int) -> bytes: + assert size >= 0 + return bytes([randint(0, 255) for _ in range(size)]) diff --git a/mixnet/test_utils.py b/mixnet/test_utils.py index e3ba2607..4ff1d4fa 100644 --- a/mixnet/test_utils.py +++ b/mixnet/test_utils.py @@ -1,46 +1,20 @@ -import asyncio - from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey -from mixnet.bls import generate_bls from mixnet.config import ( - MixClientConfig, - MixNodeConfig, + MixMembership, MixnetConfig, - MixNodeInfo, - MixnetTopology, - MixnetTopologyConfig, - MixnetTopologySize, + NodeConfig, + NodeInfo, ) -from mixnet.utils import random_bytes - - -def with_test_timeout(t): - def wrapper(coroutine): - async def run(*args, **kwargs): - async with asyncio.timeout(t): - return await coroutine(*args, **kwargs) - return run - return wrapper - - -def init_mixnet_config() -> MixnetConfig: - topology_config = MixnetTopologyConfig( - [ - MixNodeInfo( - generate_bls(), - X25519PrivateKey.generate(), - random_bytes(32), - ) - for _ in range(12) - ], - MixnetTopologySize(3, 3), - b"entropy", - ) - mixclient_config = MixClientConfig(30, 3, MixnetTopology(topology_config)) - mixnode_config = MixNodeConfig( - topology_config.mixnode_candidates[0].encryption_private_key, 30 +def init_mixnet_config(num_nodes: int) -> MixnetConfig: + transmission_rate_per_sec = 3 + node_configs = [ + NodeConfig(X25519PrivateKey.generate(), transmission_rate_per_sec) + for _ in range(num_nodes) + ] + membership = MixMembership( + [NodeInfo(node_config.private_key) for node_config in node_configs] ) - return MixnetConfig(topology_config, mixclient_config, mixnode_config) + return MixnetConfig(node_configs, membership) diff --git a/mixnet/utils.py b/mixnet/utils.py deleted file mode 100644 index 6b45176c..00000000 --- a/mixnet/utils.py +++ /dev/null @@ -1,6 +0,0 @@ -from random import randint - - -def random_bytes(size: int) -> bytes: - assert size >= 0 - return bytes([randint(0, 255) for _ in range(size)])