Mixnet: topology update (#56)

This commit is contained in:
Youngjoon Lee 2024-02-05 07:47:36 +01:00 committed by GitHub
parent d7b5e0b529
commit b1ffb4d62d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 207 additions and 132 deletions

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from mixnet.mixnet import Mixnet, MixnetTopology
from mixnet.mixnet import Mixnet
from mixnet.node import PacketQueue
from mixnet.packet import PacketBuilder
from mixnet.poisson import poisson_interval_sec
@ -10,7 +10,6 @@ from mixnet.poisson import poisson_interval_sec
async def mixclient_emitter(
mixnet: Mixnet,
topology: MixnetTopology,
emission_rate_per_min: int, # Poisson rate parameter: lambda in the spec
redundancy: int, # b in the spec
real_packet_queue: PacketQueue,
@ -38,7 +37,6 @@ async def mixclient_emitter(
try:
await emit(
mixnet,
topology,
redundancy,
real_packet_queue,
redundant_real_packet_queue,
@ -51,7 +49,6 @@ async def mixclient_emitter(
async def emit(
mixnet: Mixnet,
topology: MixnetTopology,
redundancy: int, # b in the spec
real_packet_queue: PacketQueue,
redundant_real_packet_queue: PacketQueue,
@ -69,7 +66,7 @@ async def emit(
redundant_real_packet_queue.put_nowait((addr, packet))
await outbound_socket.put((addr, packet))
packet, route = PacketBuilder.drop_cover(b"drop cover", mixnet, topology).next()
packet, route = PacketBuilder.drop_cover(b"drop cover", mixnet).next()
await outbound_socket.put((route[0].addr, packet))

View File

@ -4,44 +4,67 @@ import random
from dataclasses import dataclass
from typing import List
from mixnet.fisheryates import FisherYates
from mixnet.node import MixNode
@dataclass
class Mixnet:
mix_nodes: List[MixNode]
__topology: MixnetTopology | None = None
# Build a new topology deterministically using an entropy.
# The entropy is expected to be injected from outside.
#
# TODO: Implement constructing a new topology in advance to minimize the topology transition time.
# https://www.notion.so/Mixnet-Specification-807b624444a54a4b88afa1cc80e100c2?pvs=4#9a7f6089e210454bb11fe1c10fceff68
def build_topology(
self,
entropy: bytes,
n_layers: int,
n_nodes_per_layer: int,
) -> MixnetTopology:
num_nodes = n_nodes_per_layer * n_layers
assert num_nodes < len(self.mix_nodes)
def get_topology(self) -> MixnetTopology:
if self.__topology is None:
raise RuntimeError("topology is not set yet")
return self.__topology
shuffled = FisherYates.shuffle(self.mix_nodes, entropy)
sampled = shuffled[:num_nodes]
layers = []
for l in range(n_layers):
start = l * n_nodes_per_layer
layer = sampled[start : start + n_nodes_per_layer]
layers.append(layer)
return MixnetTopology(layers)
def set_topology(self, topology: MixnetTopology) -> None:
"""
Replace the old topology with the new topology received, and start establishing new network connections in background.
def choose_mixnode(self) -> MixNode:
return random.choice(self.mix_nodes)
In real implementations, this method should be a long-running task, accepting topologies periodically.
Here in the spec, this method has been simplified as a setter, assuming the single-thread test environment.
"""
self.__topology = topology
self.__establish_connections()
def __establish_connections(self) -> None:
"""
Establish network connections in advance based on the topology received.
This is just a preparation to forward subsequent packets as quickly as possible,
but this is not a strict requirement.
In real implementations, this should be a background task.
"""
pass
@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[MixNode]]
def generate_route(self) -> list[MixNode]:
return [random.choice(layer) for layer in self.layers]
def generate_route(self, mix_destination: MixNode) -> list[MixNode]:
"""
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)
return route
def choose_mix_destination(self) -> MixNode:
"""
Choose a mix node from the last mix layer as a mix destination
that will reconstruct a message from Sphinx packets.
"""
return random.choice(self.layers[-1])
@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

View File

@ -9,7 +9,7 @@ from typing import Dict, Iterator, List, Self, Tuple, TypeAlias
from pysphinx.payload import Payload
from pysphinx.sphinx import SphinxPacket
from mixnet.mixnet import Mixnet, MixnetTopology, MixNode
from mixnet.mixnet import Mixnet, MixNode
class MessageFlag(Enum):
@ -28,9 +28,9 @@ class PacketBuilder:
flag: MessageFlag,
message: bytes,
mixnet: Mixnet,
topology: MixnetTopology,
):
destination = mixnet.choose_mixnode()
topology = mixnet.get_topology()
destination = topology.choose_mix_destination()
msg_with_flag = flag.bytes() + message
# NOTE: We don't encrypt msg_with_flag for destination.
@ -39,7 +39,7 @@ class PacketBuilder:
packets_and_routes = []
for fragment in fragment_set.fragments:
route = topology.generate_route()
route = topology.generate_route(destination)
packet = SphinxPacket.build(
fragment.bytes(),
[mixnode.sphinx_node() for mixnode in route],
@ -50,14 +50,12 @@ class PacketBuilder:
self.iter = iter(packets_and_routes)
@classmethod
def real(cls, message: bytes, mixnet: Mixnet, topology: MixnetTopology) -> Self:
return cls(MessageFlag.MESSAGE_FLAG_REAL, message, mixnet, topology)
def real(cls, message: bytes, mixnet: Mixnet) -> Self:
return cls(MessageFlag.MESSAGE_FLAG_REAL, message, mixnet)
@classmethod
def drop_cover(
cls, message: bytes, mixnet: Mixnet, topology: MixnetTopology
) -> Self:
return cls(MessageFlag.MESSAGE_FLAG_DROP_COVER, message, mixnet, topology)
def drop_cover(cls, message: bytes, mixnet: Mixnet) -> Self:
return cls(MessageFlag.MESSAGE_FLAG_DROP_COVER, message, mixnet)
def next(self) -> Tuple[SphinxPacket, List[MixNode]]:
return next(self.iter)

63
mixnet/robustness.py Normal file
View File

@ -0,0 +1,63 @@
from __future__ import annotations
from typing import List
from mixnet.fisheryates import FisherYates
from mixnet.mixnet import (
Mixnet,
MixnetTopology,
MixnetTopologySize,
)
from mixnet.node import MixNode
class Robustness:
"""
A robustness layer is placed on top of a mixnet layer and a consensus layer,
to separate their responsibilities and minimize dependencies between them.
For v1, the role of robustness layer is building a new mixnet topology
and injecting it to the mixnet layer,
whenever a new entropy is received from the consensus layer.
A static list of nodes is used for building topologies deterministically.
This can be changed in later versions.
In later versions, the robustness layer will have more responsibilities.
"""
def __init__(
self,
mixnode_candidates: List[MixNode],
mixnet_topology_size: MixnetTopologySize,
mixnet: Mixnet,
) -> None:
assert mixnet_topology_size.num_total_mixnodes() <= len(mixnode_candidates)
self.mixnode_candidates = mixnode_candidates
self.mixnet_topology_size = mixnet_topology_size
self.mixnet = mixnet
def set_entropy(self, entropy: bytes) -> None:
"""
Given a entropy received, build a new topology and send it to mixnet.
In real implementations, this method should be a long-running task, consuming entropy periodically.
Here in the spec, this method has been simplified as a setter, assuming the single-thread test environment.
"""
topology = self.build_topology(entropy)
self.mixnet.set_topology(topology)
def build_topology(self, entropy: bytes) -> MixnetTopology:
"""
Build a new topology deterministically using an entropy and a given set of candidates.
"""
shuffled = FisherYates.shuffle(self.mixnode_candidates, entropy)
sampled = shuffled[: self.mixnet_topology_size.num_total_mixnodes()]
layers = []
for layer_id in range(self.mixnet_topology_size.num_layers):
start = layer_id * self.mixnet_topology_size.num_mixnodes_per_layer
layer = sampled[
start : start + self.mixnet_topology_size.num_mixnodes_per_layer
]
layers.append(layer)
return MixnetTopology(layers)

View File

@ -1,25 +1,21 @@
import asyncio
from datetime import datetime
from typing import Tuple
from unittest import IsolatedAsyncioTestCase
import numpy
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from mixnet.bls import generate_bls
from mixnet.client import mixclient_emitter
from mixnet.mixnet import Mixnet, MixnetTopology
from mixnet.node import MixNode, PacketQueue
from mixnet.node import PacketQueue
from mixnet.packet import PacketBuilder
from mixnet.poisson import poisson_mean_interval_sec
from mixnet.utils import random_bytes
from mixnet.test_mixnet import TestMixnet
from mixnet.test_utils import with_test_timeout
from mixnet.utils import random_bytes
class TestMixClient(IsolatedAsyncioTestCase):
class TestMixClient(TestMixnet):
@with_test_timeout(100)
async def test_mixclient_emitter(self):
mixnet, topology = self.init()
mixnet, _ = self.init()
real_packet_queue: PacketQueue = asyncio.Queue()
outbound_socket: PacketQueue = asyncio.Queue()
@ -28,7 +24,6 @@ class TestMixClient(IsolatedAsyncioTestCase):
_ = asyncio.create_task(
mixclient_emitter(
mixnet,
topology,
emission_rate_per_min,
redundancy,
real_packet_queue,
@ -37,7 +32,7 @@ class TestMixClient(IsolatedAsyncioTestCase):
)
# Create packets. At least two packets are expected to be generated from a 3500-byte msg
builder = PacketBuilder.real(random_bytes(3500), mixnet, topology)
builder = PacketBuilder.real(random_bytes(3500), mixnet)
# Schedule two packets to the mix client without any interval
packet, route = builder.next()
await real_packet_queue.put((route[0].addr, packet))
@ -61,18 +56,3 @@ class TestMixClient(IsolatedAsyncioTestCase):
poisson_mean_interval_sec(emission_rate_per_min),
delta=1.0,
)
@staticmethod
def init() -> Tuple[Mixnet, MixnetTopology]:
mixnet = Mixnet(
[
MixNode(
generate_bls(),
X25519PrivateKey.generate(),
random_bytes(32),
)
for _ in range(12)
]
)
topology = mixnet.build_topology(b"entropy", 3, 3)
return mixnet, topology

View File

@ -1,21 +1,40 @@
from unittest import TestCase
from typing import Tuple
from unittest import IsolatedAsyncioTestCase
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from mixnet.bls import generate_bls
from mixnet.mixnet import Mixnet, MixNode
from mixnet.mixnet import Mixnet, MixnetTopologySize, MixNode
from mixnet.robustness import Robustness
from mixnet.utils import random_bytes
class TestMixnet(TestCase):
def test_build_topology(self):
nodes = [
MixNode(generate_bls(), X25519PrivateKey.generate(), random_bytes(32))
for _ in range(12)
]
mixnet = Mixnet(nodes)
class TestMixnet(IsolatedAsyncioTestCase):
@staticmethod
def init() -> Tuple[Mixnet, Robustness]:
mixnet = Mixnet()
robustness = Robustness(
[
MixNode(
generate_bls(),
X25519PrivateKey.generate(),
random_bytes(32),
)
for _ in range(12)
],
MixnetTopologySize(3, 3),
mixnet,
)
robustness.set_entropy(b"entropy")
topology = mixnet.build_topology(b"entropy", 3, 3)
self.assertEqual(len(topology.layers), 3)
for layer in topology.layers:
self.assertEqual(len(layer), 3)
return (mixnet, robustness)
def test_topology_from_robustness(self):
mixnet, robustness = self.init()
topology1 = mixnet.get_topology()
robustness.set_entropy(b"new entropy")
topology2 = mixnet.get_topology()
self.assertNotEqual(topology1, topology2)

View File

@ -1,22 +1,17 @@
import asyncio
from datetime import datetime
from typing import Tuple
from unittest import IsolatedAsyncioTestCase
import numpy
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from pysphinx.sphinx import SphinxPacket
from mixnet.bls import generate_bls
from mixnet.mixnet import Mixnet, MixnetTopology
from mixnet.node import MixNode, NodeAddress, PacketPayloadQueue, PacketQueue
from mixnet.node import NodeAddress, PacketPayloadQueue, PacketQueue
from mixnet.packet import PacketBuilder
from mixnet.poisson import poisson_interval_sec, poisson_mean_interval_sec
from mixnet.test_mixnet import TestMixnet
from mixnet.test_utils import with_test_timeout
from mixnet.utils import random_bytes
class TestMixNodeRunner(IsolatedAsyncioTestCase):
class TestMixNodeRunner(TestMixnet):
@with_test_timeout(180)
async def test_mixnode_runner_emission_rate(self):
"""
@ -26,11 +21,11 @@ class TestMixNodeRunner(IsolatedAsyncioTestCase):
and if processing is delayed according to an exponential distribution with a rate `mu`,
the rate of outputs should be `lambda`.
"""
mixnet, topology = self.init()
mixnet, _ = self.init()
inbound_socket: PacketQueue = asyncio.Queue()
outbound_socket: PacketPayloadQueue = asyncio.Queue()
packet, route = PacketBuilder.real(b"msg", mixnet, topology).next()
packet, route = PacketBuilder.real(b"msg", mixnet).next()
delay_rate_per_min = 30 # mu (= 2s delay on average)
# Start only the first mix node for testing
@ -107,18 +102,3 @@ class TestMixNodeRunner(IsolatedAsyncioTestCase):
await asyncio.sleep(poisson_interval_sec(rate_per_min))
await inbound_socket.put((node_addr, packet))
await sent_packet_queue.put((node_addr, packet))
@staticmethod
def init() -> Tuple[Mixnet, MixnetTopology]:
mixnet = Mixnet(
[
MixNode(
generate_bls(),
X25519PrivateKey.generate(),
random_bytes(32),
)
for _ in range(12)
]
)
topology = mixnet.build_topology(b"entropy", 3, 3)
return mixnet, topology

View File

@ -1,11 +1,7 @@
from typing import List, Tuple
from unittest import TestCase
from typing import List
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from pysphinx.sphinx import ProcessedFinalHopPacket, SphinxPacket
from mixnet.bls import generate_bls
from mixnet.mixnet import Mixnet, MixnetTopology
from mixnet.node import MixNode
from mixnet.packet import (
Fragment,
@ -13,15 +9,16 @@ from mixnet.packet import (
MessageReconstructor,
PacketBuilder,
)
from mixnet.test_mixnet import TestMixnet
from mixnet.utils import random_bytes
class TestPacket(TestCase):
class TestPacket(TestMixnet):
def test_real_packet(self):
mixnet, topology = self.init()
mixnet, _ = self.init()
msg = random_bytes(3500)
builder = PacketBuilder.real(msg, mixnet, topology)
builder = PacketBuilder.real(msg, mixnet)
packet0, route0 = builder.next()
packet1, route1 = builder.next()
packet2, route2 = builder.next()
@ -46,10 +43,10 @@ class TestPacket(TestCase):
)
def test_cover_packet(self):
mixnet, topology = self.init()
mixnet, _ = self.init()
msg = b"cover"
builder = PacketBuilder.drop_cover(msg, mixnet, topology)
builder = PacketBuilder.drop_cover(msg, mixnet)
packet, route = builder.next()
self.assertRaises(StopIteration, builder.next)
@ -61,21 +58,6 @@ class TestPacket(TestCase):
(MessageFlag.MESSAGE_FLAG_DROP_COVER, msg),
)
@staticmethod
def init() -> Tuple[Mixnet, MixnetTopology]:
mixnet = Mixnet(
[
MixNode(
generate_bls(),
X25519PrivateKey.generate(),
random_bytes(32),
)
for _ in range(12)
]
)
topology = mixnet.build_topology(b"entropy", 3, 3)
return mixnet, topology
@staticmethod
def process_packet(packet: SphinxPacket, route: List[MixNode]) -> Fragment:
processed = packet.process(route[0].encryption_private_key)

33
mixnet/test_robustness.py Normal file
View File

@ -0,0 +1,33 @@
from unittest import TestCase
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from mixnet.bls import generate_bls
from mixnet.mixnet import Mixnet, MixnetTopologySize, MixNode
from mixnet.robustness import Robustness
from mixnet.utils import random_bytes
class TestRobustness(TestCase):
def test_build_topology(self):
robustness = Robustness(
[
MixNode(
generate_bls(),
X25519PrivateKey.generate(),
random_bytes(32),
)
for _ in range(12)
],
MixnetTopologySize(3, 3),
Mixnet(),
)
topology = robustness.build_topology(b"entropy")
self.assertEqual(
len(topology.layers), robustness.mixnet_topology_size.num_layers
)
for layer in topology.layers:
self.assertEqual(
len(layer), robustness.mixnet_topology_size.num_mixnodes_per_layer
)