MiniTor is a simplified, educational implementation of an anonymous network, directly inspired by the architecture of Tor (The Onion Router).
It implements the core ideas of onion routing: layered hybrid encryption (RSA + AES), circuits of multiple relays, fixed-size cells, replies that travel back through the circuit, and exit policies. No single relay knows both who sent a message and where it's going.
MiniTor mirrors the Tor network with four components:
-
Directory Server (
directory_server.py)- Acts as the network consensus (like Tor's Directory Authorities).
- Relays register their address, port, and RSA public key, then keep re-registering as a heartbeat. Relays that go silent are dropped after a TTL.
- Clients fetch the list of active relays from it to build circuits.
-
Relay Nodes (
relay_node.py)- Receive a fixed-size cell, peel exactly one layer of encryption, and forward the rest to the next hop.
- A relay only learns its role (entry, middle, or exit) from the layer it decrypts.
- An exit node either delivers the message to itself or, if its exit policy allows it, connects to a destination and returns the response.
- Replies are encrypted again at every hop on the way back to the client.
-
Client (
client.py)- Picks a random 3-hop circuit (Entry → Middle → Exit) using a cryptographically secure RNG.
- Wraps the message in one layer per hop, sends it, and verifies the reply.
- If a relay turns out to be offline, it retries with a new circuit.
-
Echo Server (
echo_server.py)- A demo destination for exit traffic. Its reply shows the address it saw, which is the exit relay's address and never the client's.
┌──────────────────┐
│ Directory Server │ ◄── relays register + heartbeat
└────────▲─────────┘
│ GET_RELAYS
┌────────┐ cell ┌─┴─────┐ cell ┌────────┐ cell ┌──────┐ plain ┌─────────────┐
│ Client │ ──────► │ Entry │ ─────► │ Middle │ ─────► │ Exit │ ──────► │ Destination │
│ │ ◄────── │ │ ◄───── │ │ ◄───── │ │ ◄────── │ (optional) │
└────────┘ reply └───────┘ reply └────────┘ reply └──────┘ └─────────────┘
.
├── minitor/
│ ├── crypto/
│ │ ├── crypto_utils.py # RSA-OAEP, AES-GCM, AES-CTR primitives
│ │ └── onion.py # Cell format: build/peel onions, build/open replies
│ └── network/
│ ├── transport.py # Exact reads, framed JSON, threaded TCP server
│ ├── directory_server.py # Directory Authority
│ ├── relay_node.py # Relay nodes, exit policy, replay protection
│ ├── client.py # Circuit builder / CLI
│ └── echo_server.py # Demo destination
├── tests/ # pytest suite (unit + end-to-end)
├── .github/workflows/tests.yml # CI on Linux, Windows, and macOS
├── docker-compose.yml # Local multi-node network
├── Dockerfile
├── pyproject.toml
└── requirements.txt
MiniTor uses hybrid encryption. RSA-2048 (OAEP/SHA-256) protects small per-hop session keys, and AES-256 encrypts the actual data.
- Circuit building: The client picks 3 relays: N1 (Entry), N2 (Middle), and N3 (Exit).
- Layer 3 (Exit): The client generates fresh keys for N3 and encrypts
EXIT | destination | messagewith AES-GCM. It encrypts the keys with N3's RSA public key. - Layer 2 (Middle): The client prepends
RELAY | address of N3to layer 3, encrypts everything with N2's fresh keys, and encrypts those keys with N2's public key. - Layer 1 (Entry): It does the same for N1, pointing to N2.
- The result is padded with random bytes to a fixed 4096-byte cell and sent to N1.
Every packet on every link is exactly CELL_SIZE (4096) bytes, in both directions:
┌──────────────────────────────┬────────────────────────────────────┬────────────────┐
│ RSA-OAEP(relay public key, │ AES-256-GCM(forward key, │ random padding │
│ forward key ‖ backward key │ command ‖ next address ‖ inner) │ │
│ ‖ layer length) 256 B │ nonce ‖ ciphertext ‖ tag │ │
└──────────────────────────────┴────────────────────────────────────┴────────────────┘
When N1 receives the cell, it:
- decrypts the RSA block to get its two session keys and the real length of its layer,
- decrypts and authenticates its layer with AES-GCM. Any tampering is detected here and the cell is dropped,
- finds the routing instruction ("forward to N2"),
- pads the inner layer back up to 4096 bytes with random data and forwards it.
N1 knows who the client is but not the destination. N3 sees the message (and destination) but only knows it came from N2. Because every cell is the same size, someone watching the network can't tell a relay's position from packet sizes.
Each hop's RSA block also carries a backward key:
- The exit builds a fixed-size reply (
status ‖ length ‖ data ‖ padding) and encrypts it with AES-GCM under its backward key. - Each relay on the way back adds its own AES-CTR layer, which doesn't change the size.
- The client removes the CTR layers, then checks the exit's GCM tag, so tampering anywhere on the return path is detected.
- Replay protection: relays remember recently seen cells and drop duplicates, so an attacker can't replay a captured cell to find out where it goes.
- Exit policies: exit nodes refuse to connect anywhere unless it's explicitly allowed (
--allow-exit host:port), so relays aren't open proxies. - Input validation: the directory rejects malformed registrations and invalid keys; all TCP reads are length-checked, with timeouts.
Docker Compose starts a Directory Server, 3 Relay Nodes, and the Echo Server.
- Start the network
docker compose up -d --build
- Watch the logs (in a separate terminal)
docker compose logs -f
- Run the interactive client
Type a message. In the logs, the entry and middle relays each peel one layer, the exit delivers the message, and the reply travels back.
docker compose run --rm client
- Send traffic to a destination through the exit node
The echo server replies with the IP it saw, which is the exit relay's.
docker compose run --rm client python -m minitor.network.client \ --dir-host dir-server --dest echo-server:7000 -m "Who am I?" - Stop everything
docker compose down
- Create a virtual environment and install MiniTor with its dev dependencies:
python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate pip install -e ".[dev]"
- Start the Directory Server and the demo destination:
minitor-directory minitor-echo --port 7000
- Start 3 Relay Nodes in separate terminals (they retry until the directory is up):
minitor-relay --port 8001 --allow-exit 127.0.0.1:7000 minitor-relay --port 8002 --allow-exit 127.0.0.1:7000 minitor-relay --port 8003 --allow-exit 127.0.0.1:7000
- Run the client:
minitor-client # interactive minitor-client -m "Hello onion" # one message minitor-client --dest 127.0.0.1:7000 -m "Who am I?" # via the exit to the echo server
The python -m minitor.network.<module> form works too, if you don't want to install the package.
| Command | Useful options |
|---|---|
minitor-directory |
--host, --port (8000), --relay-ttl (30s), -v |
minitor-relay |
--port (8001), --dir-host, --dir-port, --public-host, --allow-exit HOST:PORT (repeatable, HOST:* allowed), -v |
minitor-client |
--dir-host, --dir-port, --hops (3), --dest HOST:PORT, -m MESSAGE, --timeout, -v |
minitor-echo |
--host, --port (7000) |
pytestThe suite covers the crypto primitives, the cell format (including tampering, wrong keys, size limits, and replies), TCP framing, the directory server (heartbeats, expiry, validation), and end-to-end runs of a real network on local ports (replies, exit policy, replay protection, and circuit retry when a relay is down). CI runs it on Linux, Windows, and macOS.
MIT License. Feel free to fork, expand