From 1bc2703b0d3c6b5c451f19ba9409defe963f3da1 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:09:49 +0300 Subject: [PATCH 01/10] traffic-masking: add pytest/ruff tooling, native tests, Docker 3.14 base - Add cf-ddns-style scaffold: requirements-dev.txt (pytest/pytest-cov/ruff), Makefile (venv/test/test-fast/test-live/lint/clean), .gitignore, and a top-level pytest suite (conftest live harness, test_imports, test_core). - Convert the two standalone runners into native pytest tests: unit checks plus bounded live client/server tests in test_live.py; remove the runners so nothing runs outside pytest. - Pin requirements.txt via pip freeze at the latest numpy (2.5.1). - Bump the Docker base image to python:3.14-alpine (matches CI); the image builds with a prebuilt numpy wheel and runs on Python 3.14. - Mechanical lint cleanup: drop unused/duplicate imports, replace bare excepts, remove dead local variables. No runtime behaviour change. --- traffic-masking/.gitignore | 7 + traffic-masking/Dockerfile | 2 +- traffic-masking/Makefile | 45 ++ traffic-masking/conftest.py | 96 +++ traffic-masking/enhanced/entropy.py | 2 +- traffic-masking/enhanced/ml_resistance.py | 3 +- traffic-masking/enhanced/state_machine.py | 2 +- traffic-masking/enhanced/timing.py | 1 - traffic-masking/masking_lib.py | 30 +- traffic-masking/requirements-dev.txt | 3 + traffic-masking/requirements.txt | 2 +- traffic-masking/test_core.py | 67 ++ traffic-masking/test_imports.py | 38 + traffic-masking/test_live.py | 111 +++ traffic-masking/test_realistic_patterns.py | 392 ---------- traffic-masking/test_traffic_masking.py | 861 --------------------- 16 files changed, 380 insertions(+), 1282 deletions(-) create mode 100644 traffic-masking/.gitignore create mode 100644 traffic-masking/Makefile create mode 100644 traffic-masking/conftest.py create mode 100644 traffic-masking/requirements-dev.txt create mode 100644 traffic-masking/test_core.py create mode 100644 traffic-masking/test_imports.py create mode 100644 traffic-masking/test_live.py delete mode 100755 traffic-masking/test_realistic_patterns.py delete mode 100755 traffic-masking/test_traffic_masking.py diff --git a/traffic-masking/.gitignore b/traffic-masking/.gitignore new file mode 100644 index 0000000..08f0246 --- /dev/null +++ b/traffic-masking/.gitignore @@ -0,0 +1,7 @@ +venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ +*.log diff --git a/traffic-masking/Dockerfile b/traffic-masking/Dockerfile index ba00dd9..928c84e 100644 --- a/traffic-masking/Dockerfile +++ b/traffic-masking/Dockerfile @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Use the official Python image based on Alpine -FROM python:3.13-alpine +FROM python:3.14-alpine LABEL maintainer="kogeler" LABEL description="UDP Traffic Masking System" diff --git a/traffic-masking/Makefile b/traffic-masking/Makefile new file mode 100644 index 0000000..5123e5a --- /dev/null +++ b/traffic-masking/Makefile @@ -0,0 +1,45 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +VENV := venv +PYTHON := $(VENV)/bin/python +PIP := $(VENV)/bin/pip +RUFF := $(VENV)/bin/ruff +DEPS_STAMP := $(VENV)/.deps-installed + +COV := --cov=masking_lib --cov=traffic_masking_server --cov=traffic_masking_client \ + --cov=enhanced --cov-branch --cov-report=term-missing + +.PHONY: venv test test-fast test-live lint run-server run-client clean + +$(PYTHON): + python3 -m venv $(VENV) + +$(DEPS_STAMP): $(PYTHON) requirements.txt requirements-dev.txt + $(PIP) install --upgrade pip + $(PIP) install -r requirements.txt -r requirements-dev.txt + touch $(DEPS_STAMP) + +venv: $(DEPS_STAMP) ## create venv and install changed deps + +test: venv ## run the full test suite (unit + live) + $(PYTHON) -m pytest -v $(COV) + +test-fast: venv ## run only fast unit tests (skip live) + $(PYTHON) -m pytest -v -m "not live" $(COV) + +test-live: venv ## run only the live end-to-end tests + $(PYTHON) -m pytest -v -m live + +lint: venv ## static checks + $(RUFF) check . + +run-server: venv ## demo server (floating 2-8 Mbps, advanced) + $(PYTHON) traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced --profile mixed + +run-client: venv ## demo client against 127.0.0.1 + $(PYTHON) traffic_masking_client.py --server 127.0.0.1 --advanced --uplink-profile mixed + +clean: ## remove venv and generated artifacts + rm -rf $(VENV) __pycache__ enhanced/__pycache__ \ + .pytest_cache .ruff_cache .coverage htmlcov *.log diff --git a/traffic-masking/conftest.py b/traffic-masking/conftest.py new file mode 100644 index 0000000..ba6d1c5 --- /dev/null +++ b/traffic-masking/conftest.py @@ -0,0 +1,96 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Shared fixtures and the live-process harness for the traffic-masking suite. + +Test modules live at the project top level (uniform with cf-ddns), so they import +the runtime modules directly. The `live` marker is registered here instead of in a +separate pytest.ini. +""" + +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +BASE_DIR = Path(__file__).resolve().parent +SERVER = str(BASE_DIR / "traffic_masking_server.py") +CLIENT = str(BASE_DIR / "traffic_masking_client.py") + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "live: bounded end-to-end tests that spawn real client/server subprocesses", + ) + + +def free_udp_port(): + """Return a currently-free UDP port on loopback.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + finally: + sock.close() + + +def read_log(log_path): + """Return the current contents of a spawned process log (empty if absent).""" + try: + return Path(log_path).read_text(errors="replace") + except FileNotFoundError: + return "" + + +def wait_for(log_path, needle, timeout): + """Poll a process log until it contains `needle` or the timeout elapses.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if needle in read_log(log_path): + return True + time.sleep(0.1) + return False + + +def last_match(log_path, pattern): + """Return the last regex group-1 match in a log as float, or None.""" + import re + + values = re.findall(pattern, read_log(log_path)) + return float(values[-1]) if values else None + + +@pytest.fixture +def spawn(tmp_path): + """Launch traffic-masking scripts as subprocesses; guarantee teardown. + + Returns spawn(script, args, name) -> (Popen, log_path). + """ + procs = [] + + def _spawn(script, args, name): + log_path = tmp_path / f"{name}.log" + handle = open(log_path, "w") + proc = subprocess.Popen( + [sys.executable, script, *args], + stdout=handle, + stderr=subprocess.STDOUT, + ) + procs.append((proc, handle)) + return proc, str(log_path) + + yield _spawn + + for proc, handle in procs: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + handle.close() diff --git a/traffic-masking/enhanced/entropy.py b/traffic-masking/enhanced/entropy.py index 41fd3ac..108de5f 100644 --- a/traffic-masking/enhanced/entropy.py +++ b/traffic-masking/enhanced/entropy.py @@ -13,7 +13,7 @@ import random import hashlib import math -from typing import Dict, Any, List, Optional, Tuple +from typing import Dict, Any, List, Optional from collections import Counter from enum import Enum diff --git a/traffic-masking/enhanced/ml_resistance.py b/traffic-masking/enhanced/ml_resistance.py index e0f2d7e..cce1bc2 100644 --- a/traffic-masking/enhanced/ml_resistance.py +++ b/traffic-masking/enhanced/ml_resistance.py @@ -11,8 +11,7 @@ import random import time import math -from typing import Dict, List, Tuple, Any, Optional -from enum import Enum +from typing import Dict, List, Tuple, Any from collections import deque diff --git a/traffic-masking/enhanced/state_machine.py b/traffic-masking/enhanced/state_machine.py index 361199a..a020a2f 100644 --- a/traffic-masking/enhanced/state_machine.py +++ b/traffic-masking/enhanced/state_machine.py @@ -10,7 +10,7 @@ import random import time -from typing import Dict, List, Any, Optional, Tuple +from typing import Dict, Any, Tuple from enum import Enum from collections import deque, defaultdict diff --git a/traffic-masking/enhanced/timing.py b/traffic-masking/enhanced/timing.py index 5f45074..5447505 100644 --- a/traffic-masking/enhanced/timing.py +++ b/traffic-masking/enhanced/timing.py @@ -9,7 +9,6 @@ """ import random -import time from collections import deque from typing import Optional, Dict, Any diff --git a/traffic-masking/masking_lib.py b/traffic-masking/masking_lib.py index e8665dc..bd4b52b 100644 --- a/traffic-masking/masking_lib.py +++ b/traffic-masking/masking_lib.py @@ -21,15 +21,12 @@ import struct import random import socket -import hashlib import time import math from enum import Enum from dataclasses import dataclass -from collections import deque from typing import Iterator, List, Optional, Sequence, Tuple, Dict, Any, Union, Callable -import time # Add time import for rate control try: import numpy as np @@ -42,7 +39,7 @@ from enhanced.correlation import CorrelationBreaker from enhanced.ml_resistance import MLResistantGenerator from enhanced.entropy import EntropyEnhancer - from enhanced.state_machine import ProtocolStateMachine + from enhanced.state_machine import ProtocolStateMachine # noqa: F401 (availability probe) ENHANCED_AVAILABLE = True except ImportError: ENHANCED_AVAILABLE = False @@ -89,7 +86,7 @@ def __init__(self): self.correlation_breaker = CorrelationBreaker() self.ml_resistant = MLResistantGenerator() self.timing_model = AdaptiveTimingModel() - except: + except Exception: self.enhanced = False @staticmethod @@ -225,7 +222,7 @@ def __init__( try: self.timing_model = AdaptiveTimingModel() self.entropy_enhancer = EntropyEnhancer() - except: + except Exception: self.enhanced = False def obfuscate(self, payload: bytes, profile: Optional[TrafficProfile] = None, base_delay: float = 0.0) -> Tuple[List[bytes], float]: @@ -383,7 +380,7 @@ def _generate_payload(size: int, entropy: float = 1.0) -> bytes: try: enhancer = EntropyEnhancer() return enhancer.generate_realistic_encrypted_payload(size, content_type='mixed') - except: + except Exception: pass # Fast path for high entropy (most common case) @@ -427,8 +424,6 @@ def stream_generator( if min_mbps is not None and max_mbps is not None: # Floating rate mode - start at a random position for variety current_mbps = random.uniform(min_mbps, max_mbps) - rate_velocity = random.uniform(-0.5, 0.5) * (max_mbps - min_mbps) # Initial velocity - rate_acceleration = 0.0 # Acceleration last_rate_update = time.time() use_floating_rate = True elif target_mbps is not None: @@ -445,14 +440,12 @@ def stream_generator( rate_window_start = time.time() # Use enhanced features if available - enhanced_generator = None if ENHANCED_AVAILABLE and target_mbps and target_mbps > 10: # Only use enhanced for high rates try: - ml_generator = MLResistantGenerator() - timing_model = AdaptiveTimingModel(base_rtt=0.001) # Lower base RTT for higher throughput - enhanced_generator = True - except: - enhanced_generator = None + MLResistantGenerator() + AdaptiveTimingModel(base_rtt=0.001) # Lower base RTT for higher throughput + except Exception: + pass steps = ProtocolMimicry.for_profile(profile) if not steps: @@ -483,7 +476,6 @@ def stream_generator( rate_pattern_phase = random.uniform(0, 2 * math.pi) # Random starting phase dwell_at_boundary = False dwell_remaining = 0 - last_boundary_visited = 'none' # Track last boundary while True: # Update floating rate if enabled @@ -509,7 +501,6 @@ def stream_generator( current_mbps = max_mbps else: current_mbps = min_mbps - rate_velocity = 0 dwell_at_boundary = True dwell_remaining = random.uniform(2.0, 5.0) @@ -518,11 +509,6 @@ def stream_generator( dwell_remaining -= dt if dwell_remaining <= 0: dwell_at_boundary = False - # Strong push away from boundary - if current_mbps <= min_mbps + 0.1: - rate_velocity = rate_range * random.uniform(1.0, 2.0) - else: - rate_velocity = -rate_range * random.uniform(1.0, 2.0) else: # Stay exactly at boundary if current_mbps < rate_center: diff --git a/traffic-masking/requirements-dev.txt b/traffic-masking/requirements-dev.txt new file mode 100644 index 0000000..f3b6533 --- /dev/null +++ b/traffic-masking/requirements-dev.txt @@ -0,0 +1,3 @@ +pytest==9.1.1 +pytest-cov==7.1.0 +ruff==0.15.21 diff --git a/traffic-masking/requirements.txt b/traffic-masking/requirements.txt index dfdb2d4..b9c1de2 100644 --- a/traffic-masking/requirements.txt +++ b/traffic-masking/requirements.txt @@ -1 +1 @@ -numpy==2.3.3 +numpy==2.5.1 diff --git a/traffic-masking/test_core.py b/traffic-masking/test_core.py new file mode 100644 index 0000000..213ce20 --- /dev/null +++ b/traffic-masking/test_core.py @@ -0,0 +1,67 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Fast in-process smoke tests for the core library (characterization baseline).""" + +import socket + +import pytest + +from masking_lib import ( + DynamicObfuscator, + ProtocolMimicry, + TrafficProfile, + parse_profile, + stream_generator, +) + + +def test_parse_profile_known_and_fallback(): + assert parse_profile("mixed") is TrafficProfile.MIXED + assert parse_profile("web") is TrafficProfile.WEB_BROWSING + # Unknown strings fall back to MIXED rather than raising. + assert parse_profile("bogus") is TrafficProfile.MIXED + + +@pytest.mark.parametrize("profile", list(TrafficProfile)) +def test_for_profile_is_nonempty(profile): + steps = ProtocolMimicry.for_profile(profile) + assert len(steps) > 0 + + +def test_obfuscator_produces_fragments(): + obf = DynamicObfuscator() + fragments, delay = obf.obfuscate(b"test packet data") + assert len(fragments) > 0 + assert delay >= 0 + + +def test_stream_generator_fixed_rate_yields(): + gen = stream_generator(TrafficProfile.MIXED, target_mbps=1.0) + fragments, delay = next(gen) + assert len(fragments) > 0 + assert delay > 0 + + +def test_stream_generator_floating_rate_yields(): + gen = stream_generator(TrafficProfile.MIXED, min_mbps=1.0, max_mbps=5.0) + fragments, delay = next(gen) + assert len(fragments) > 0 + assert delay > 0 + + +def test_loopback_udp_roundtrip(): + """Basic UDP loopback works in this environment (ported connectivity check).""" + receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + receiver.bind(("127.0.0.1", 0)) + receiver.settimeout(2.0) + port = receiver.getsockname()[1] + + sender = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + sender.sendto(b"PING", ("127.0.0.1", port)) + data, _ = receiver.recvfrom(64) + assert data == b"PING" + finally: + sender.close() + receiver.close() diff --git a/traffic-masking/test_imports.py b/traffic-masking/test_imports.py new file mode 100644 index 0000000..0c26fee --- /dev/null +++ b/traffic-masking/test_imports.py @@ -0,0 +1,38 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Import smoke tests for the core and optional enhanced modules.""" + +import importlib + +import pytest + +CORE_MODULES = [ + "masking_lib", + "traffic_masking_server", + "traffic_masking_client", +] + +ENHANCED_MODULES = [ + "enhanced.timing", + "enhanced.correlation", + "enhanced.ml_resistance", + "enhanced.entropy", + "enhanced.state_machine", +] + + +@pytest.mark.parametrize("module", CORE_MODULES) +def test_core_module_imports(module): + assert importlib.import_module(module) is not None + + +@pytest.mark.parametrize("module", ENHANCED_MODULES) +def test_enhanced_module_imports(module): + # Enhanced modules are optional at runtime; skip only if the runtime itself + # could not import them (they are present in this repo, so this should pass). + try: + mod = importlib.import_module(module) + except ImportError as exc: # pragma: no cover - only if enhanced/ is stripped + pytest.skip(f"optional module {module} unavailable: {exc}") + assert mod is not None diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py new file mode 100644 index 0000000..6ef4b25 --- /dev/null +++ b/traffic-masking/test_live.py @@ -0,0 +1,111 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Native end-to-end tests: spawn the real server and client on loopback. + +Ported from the former standalone test_traffic_masking.py and +test_realistic_patterns.py runners so nothing runs outside pytest. Bounded to stay +CI-safe. Durations are dictated by the current hard-coded keepalive/receive +timeouts; a later stage adds timing knobs to shrink them. +""" + +import re +import time + +import pytest + +from conftest import CLIENT, SERVER, free_udp_port, last_match, read_log, wait_for + +pytestmark = pytest.mark.live + + +def _server_args(port, lo=2, hi=4): + return [ + "--host", "127.0.0.1", "--port", str(port), + "--min-mbps", str(lo), "--max-mbps", str(hi), + "--advanced", "--profile", "mixed", "--stats-interval", "1", + ] + + +def test_transmission_bidirectional(spawn): + """Client connects, receives downlink and emits uplink; server sees the client.""" + port = free_udp_port() + _server, slog = spawn(SERVER, _server_args(port), "server") + assert wait_for(slog, "started", 5.0), read_log(slog) + + _client, clog = spawn( + CLIENT, + [ + "--server", "127.0.0.1", "--port", str(port), + "--response", "0.3", "--advanced", "--uplink-profile", "mixed", + "--stats-interval", "1", + ], + "client", + ) + + assert wait_for(clog, "Rx:", 10.0), read_log(clog) + assert wait_for(slog, "New client connected", 5.0), read_log(slog) + + # Let a few stats windows accumulate, then check real downlink/uplink. + time.sleep(4) + rx = last_match(clog, r"Rx:\s*([0-9.]+)\s*Mbps") + tx = last_match(clog, r"Tx:\s*([0-9.]+)\s*Mbps") + assert rx is not None and rx > 0.0, read_log(clog) + assert tx is not None and tx > 0.0, read_log(clog) + + +def test_reconnection_after_server_restart(spawn): + """Three-phase: connected -> server down (no false success) -> restarted -> resumed.""" + port = free_udp_port() + args = _server_args(port) + + server, slog = spawn(SERVER, args, "server1") + assert wait_for(slog, "started", 5.0), read_log(slog) + + _client, clog = spawn( + CLIENT, + ["--server", "127.0.0.1", "--port", str(port), "--response", "0.3", + "--stats-interval", "1"], + "client", + ) + assert wait_for(clog, "Rx:", 10.0), read_log(clog) + + # Phase 2: kill the server; the client must detect loss and must NOT falsely + # report a reconnect while the server is down. + server.terminate() + server.wait(timeout=5) + assert wait_for(clog, "Connection lost", 20.0), read_log(clog) + downtime = read_log(clog).split("Connection lost", 1)[1] + assert "Reconnected successfully" not in downtime, read_log(clog) + + # Phase 3: restart the server; the client must reconnect. + _server2, slog2 = spawn(SERVER, args, "server2") + assert wait_for(slog2, "started", 5.0), read_log(slog2) + assert wait_for(clog, "Reconnected successfully", 25.0), read_log(clog) + + +def test_floating_rate_stays_within_bounds(spawn): + """Characterization: the emitted server rate stays within a slack of [min,max]. + + (The old realistic-pattern runner's boundary-coverage "quality" scoring is + intentionally not ported: it rewards exact-boundary teleporting, a behaviour a + later stage removes.) + """ + port = free_udp_port() + lo, hi = 2.0, 6.0 + _server, slog = spawn(SERVER, _server_args(port, lo, hi), "server") + assert wait_for(slog, "started", 5.0), read_log(slog) + + _client, clog = spawn( + CLIENT, + ["--server", "127.0.0.1", "--port", str(port), "--stats-interval", "1"], + "client", + ) + assert wait_for(clog, "Rx:", 10.0), read_log(clog) + + time.sleep(6) + rates = [float(m) for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(slog))] + assert rates, read_log(slog) + assert min(rates) >= 0.0 + # Generous slack: this only guards against runaway rate, not shape quality. + assert max(rates) <= hi * 1.75, rates diff --git a/traffic-masking/test_realistic_patterns.py b/traffic-masking/test_realistic_patterns.py deleted file mode 100755 index 28a2332..0000000 --- a/traffic-masking/test_realistic_patterns.py +++ /dev/null @@ -1,392 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Test script for realistic traffic pattern generation -Verifies that floating rate creates natural variations reaching min/max boundaries -""" - -import subprocess -import time -import sys -import os -import signal -import re -import statistics -import math -from collections import defaultdict -from datetime import datetime - -def extract_rate_from_output(output_line): - """Extract rate in Mbps from server/client output""" - match = re.search(r'Rate:\s*([0-9.]+)\s*Mbps', output_line) - if match: - return float(match.group(1)) - return None - -def calculate_rate_distribution(rates, min_mbps, max_mbps, bins=10): - """Calculate distribution of rates across the range""" - if not rates: - return None - - distribution = defaultdict(int) - range_size = max_mbps - min_mbps - bin_size = range_size / bins - - for rate in rates: - if rate < min_mbps: - bin_idx = -1 # Below minimum - elif rate > max_mbps: - bin_idx = bins # Above maximum - else: - bin_idx = int((rate - min_mbps) / bin_size) - bin_idx = min(bin_idx, bins - 1) - distribution[bin_idx] += 1 - - return distribution - -def analyze_boundary_visits(rates, min_mbps, max_mbps, threshold=0.1): - """Analyze how often rates visit the boundaries""" - if not rates: - return None - - range_size = max_mbps - min_mbps - near_min_threshold = min_mbps + range_size * threshold - near_max_threshold = max_mbps - range_size * threshold - - visits = { - 'near_min': 0, - 'near_max': 0, - 'at_min': 0, - 'at_max': 0, - 'middle': 0 - } - - for rate in rates: - if rate <= min_mbps + 0.01: - visits['at_min'] += 1 - elif rate >= max_mbps - 0.01: - visits['at_max'] += 1 - elif rate <= near_min_threshold: - visits['near_min'] += 1 - elif rate >= near_max_threshold: - visits['near_max'] += 1 - else: - visits['middle'] += 1 - - return visits - -def detect_pattern_changes(rates, window_size=10): - """Detect pattern changes in rate sequence""" - if len(rates) < window_size * 2: - return [] - - changes = [] - for i in range(window_size, len(rates) - window_size): - prev_window = rates[i-window_size:i] - next_window = rates[i:i+window_size] - - prev_avg = statistics.mean(prev_window) - next_avg = statistics.mean(next_window) - prev_std = statistics.stdev(prev_window) if len(prev_window) > 1 else 0 - next_std = statistics.stdev(next_window) if len(next_window) > 1 else 0 - - # Detect significant changes - avg_change = abs(next_avg - prev_avg) - std_change = abs(next_std - prev_std) - - if avg_change > 0.5 or std_change > 0.3: - changes.append({ - 'index': i, - 'time': i, # Assuming 1 sample per second - 'avg_change': avg_change, - 'std_change': std_change - }) - - return changes - -def visualize_rate_graph(rates, min_mbps, max_mbps, width=70): - """Create ASCII graph of rate over time""" - if not rates: - return [] - - graph = [] - range_size = max_mbps - min_mbps - - # Create header - graph.append(f"Rate over time (min={min_mbps}, max={max_mbps} Mbps)") - graph.append("=" * width) - - # Create graph lines - for i, rate in enumerate(rates): - if rate < min_mbps: - position = 0 - marker = '<' # Below minimum - elif rate > max_mbps: - position = width - 1 - marker = '>' # Above maximum - else: - position = int((rate - min_mbps) / range_size * (width - 1)) - marker = '*' - - line = [' '] * width - line[position] = marker - - # Mark boundaries - min_pos = 0 - max_pos = width - 1 - if line[min_pos] == ' ': - line[min_pos] = '|' - if line[max_pos] == ' ': - line[max_pos] = '|' - - # Mark center - center_pos = width // 2 - if line[center_pos] == ' ': - line[center_pos] = '.' - - time_label = f"{i:3d}s " - rate_label = f" {rate:5.2f}" - graph.append(time_label + ''.join(line) + rate_label) - - return graph - -def test_realistic_patterns(min_mbps, max_mbps, duration=60): - """Test realistic traffic patterns""" - print(f"\n{'='*80}") - print(f" REALISTIC PATTERN TEST") - print(f" Range: {min_mbps}-{max_mbps} Mbps | Duration: {duration} seconds") - print(f"{'='*80}\n") - - server_proc = None - client_proc = None - rates = [] - - try: - # Start server - print(f"Starting server with floating rate {min_mbps}-{max_mbps} Mbps...") - server_proc = subprocess.Popen( - [sys.executable, "./traffic_masking_server.py", - "--min-mbps", str(min_mbps), - "--max-mbps", str(max_mbps), - "--advanced", - "--profile", "mixed", - "--stats-interval", "1"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - bufsize=1 - ) - - time.sleep(2) - - # Start client - print("Starting client...") - client_proc = subprocess.Popen( - [sys.executable, "./traffic_masking_client.py", - "--server", "127.0.0.1", - "--response", "0.2", - "--stats-interval", "1"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL - ) - - time.sleep(1) - - # Monitor rates - print(f"\nMonitoring rates for {duration} seconds...\n") - start_time = time.time() - - while time.time() - start_time < duration: - line = server_proc.stdout.readline() - if line and '[STATS]' in line: - rate = extract_rate_from_output(line) - if rate is not None: - rates.append(rate) - elapsed = int(time.time() - start_time) - - # Show progress - progress = elapsed / duration * 100 - status = "" - if rate <= min_mbps + 0.1: - status = "MIN" - elif rate >= max_mbps - 0.1: - status = "MAX" - print(f"[{elapsed:3d}s] Rate: {rate:5.2f} Mbps Progress: {progress:5.1f}% {status}", end='\r') - - print("\n") - - # Analyze results - if rates: - print(f"\n{'='*80}") - print(" ANALYSIS RESULTS") - print(f"{'='*80}\n") - - # Basic statistics - avg_rate = statistics.mean(rates) - median_rate = statistics.median(rates) - min_observed = min(rates) - max_observed = max(rates) - std_dev = statistics.stdev(rates) if len(rates) > 1 else 0 - - print("📊 BASIC STATISTICS:") - print(f" Samples: {len(rates)}") - print(f" Average: {avg_rate:.2f} Mbps") - print(f" Median: {median_rate:.2f} Mbps") - print(f" Min observed: {min_observed:.2f} Mbps") - print(f" Max observed: {max_observed:.2f} Mbps") - print(f" Std deviation: {std_dev:.2f} Mbps") - print(f" Range utilization: {(max_observed - min_observed) / (max_mbps - min_mbps) * 100:.1f}%") - - # Boundary analysis - visits = analyze_boundary_visits(rates, min_mbps, max_mbps) - if visits: - total = sum(visits.values()) - print(f"\n🎯 BOUNDARY VISITS:") - print(f" At minimum ({min_mbps:.1f}): {visits['at_min']} ({visits['at_min']/total*100:.1f}%)") - print(f" Near minimum: {visits['near_min']} ({visits['near_min']/total*100:.1f}%)") - print(f" Middle range: {visits['middle']} ({visits['middle']/total*100:.1f}%)") - print(f" Near maximum: {visits['near_max']} ({visits['near_max']/total*100:.1f}%)") - print(f" At maximum ({max_mbps:.1f}): {visits['at_max']} ({visits['at_max']/total*100:.1f}%)") - - # Distribution analysis - distribution = calculate_rate_distribution(rates, min_mbps, max_mbps, bins=10) - if distribution: - print(f"\n📈 RATE DISTRIBUTION (10 bins):") - range_size = max_mbps - min_mbps - bin_size = range_size / 10 - - for i in range(10): - bin_start = min_mbps + i * bin_size - bin_end = bin_start + bin_size - count = distribution.get(i, 0) - bar_len = int(count / len(rates) * 40) - bar = '█' * bar_len - percentage = count / len(rates) * 100 - print(f" [{bin_start:4.1f}-{bin_end:4.1f}]: {bar:40} {percentage:5.1f}%") - - # Pattern changes - changes = detect_pattern_changes(rates) - print(f"\n🔄 PATTERN CHANGES DETECTED: {len(changes)}") - if changes[:3]: # Show first 3 changes - for change in changes[:3]: - print(f" At {change['time']}s: avg_change={change['avg_change']:.2f}, std_change={change['std_change']:.2f}") - - # Rate graph (last 30 seconds) - if len(rates) > 30: - print(f"\n📉 RATE GRAPH (last 30 seconds):") - graph = visualize_rate_graph(rates[-30:], min_mbps, max_mbps, width=60) - for line in graph[:35]: # Show first 35 lines - print(" " + line) - - # Quality assessment - print(f"\n{'='*80}") - print(" QUALITY ASSESSMENT") - print(f"{'='*80}\n") - - # Check if pattern is realistic - boundary_coverage = (visits['at_min'] + visits['at_max'] + visits['near_min'] + visits['near_max']) / total * 100 - range_utilization = (max_observed - min_observed) / (max_mbps - min_mbps) * 100 - - quality_score = 0 - quality_notes = [] - - if boundary_coverage >= 30: - quality_score += 25 - quality_notes.append("✅ Good boundary coverage") - else: - quality_notes.append("❌ Poor boundary coverage") - - if range_utilization >= 80: - quality_score += 25 - quality_notes.append("✅ Good range utilization") - else: - quality_notes.append("❌ Limited range utilization") - - if len(changes) >= 2: - quality_score += 25 - quality_notes.append("✅ Dynamic pattern changes") - else: - quality_notes.append("❌ Static pattern") - - if std_dev >= (max_mbps - min_mbps) * 0.15: - quality_score += 25 - quality_notes.append("✅ Good variation") - else: - quality_notes.append("❌ Low variation") - - print(f"Quality Score: {quality_score}/100") - for note in quality_notes: - print(f" {note}") - - if quality_score >= 75: - print(f"\n🎉 EXCELLENT: Traffic pattern is highly realistic!") - elif quality_score >= 50: - print(f"\n✅ GOOD: Traffic pattern shows realistic variations") - else: - print(f"\n⚠️ NEEDS IMPROVEMENT: Traffic pattern lacks realism") - - return quality_score >= 50 - - else: - print("❌ No rate data collected") - return False - - except Exception as e: - print(f"\n❌ Error: {e}") - import traceback - traceback.print_exc() - return False - finally: - # Cleanup - if client_proc: - client_proc.terminate() - client_proc.wait(timeout=2) - if server_proc: - server_proc.terminate() - server_proc.wait(timeout=2) - -def main(): - print("="*80) - print(" REALISTIC TRAFFIC PATTERN TEST SUITE") - print("="*80) - - # Test different rate ranges - test_configs = [ - (1.0, 3.0, 60), # Narrow range - (2.0, 10.0, 60), # Wide range - (0.5, 2.0, 60), # Low speed range - ] - - results = [] - - for min_mbps, max_mbps, duration in test_configs: - print(f"\n\nTest {len(results)+1}: {min_mbps}-{max_mbps} Mbps") - result = test_realistic_patterns(min_mbps, max_mbps, duration) - results.append((f"{min_mbps}-{max_mbps} Mbps", result)) - time.sleep(2) - - # Summary - print("\n" + "="*80) - print(" TEST SUMMARY") - print("="*80) - - for config, passed in results: - status = "✅ PASSED" if passed else "❌ FAILED" - print(f" {config}: {status}") - - passed_count = sum(1 for _, passed in results if passed) - print(f"\nOverall: {passed_count}/{len(results)} tests passed") - - if passed_count == len(results): - print("\n🎉 All tests passed! Traffic patterns are realistic.") - elif passed_count > 0: - print("\n⚠️ Some tests passed. Review failed configurations.") - else: - print("\n❌ All tests failed. Pattern generation needs improvement.") - -if __name__ == "__main__": - main() diff --git a/traffic-masking/test_traffic_masking.py b/traffic-masking/test_traffic_masking.py deleted file mode 100755 index 63d2fb7..0000000 --- a/traffic-masking/test_traffic_masking.py +++ /dev/null @@ -1,861 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Unified test suite for Traffic Masking System -Tests all modules and verifies real data transmission -""" - -import argparse -import os -import re -import signal -import socket -import subprocess -import sys -import threading -import time -from pathlib import Path - -# Test results storage -test_results = {"modules": {}, "integration": {}, "transmission": {}} - - -def print_header(title): - """Print formatted section header""" - print() - print("=" * 60) - print(f" {title}") - print("=" * 60) - print() - - -def test_module_imports(): - """Test that all required modules can be imported""" - print_header("MODULE IMPORT TEST") - - modules = [ - ("masking_lib", "Core library"), - ("traffic_masking_server", "Server module"), - ("traffic_masking_client", "Client module"), - ] - - # Optional enhanced modules - enhanced_modules = [ - ("enhanced.timing", "Adaptive timing"), - ("enhanced.correlation", "Correlation breaker"), - ("enhanced.ml_resistance", "ML resistance"), - ("enhanced.entropy", "Entropy enhancer"), - ("enhanced.state_machine", "Protocol state machine"), - ] - - all_passed = True - - # Test core modules - for module_name, description in modules: - try: - __import__(module_name) - print(f"✓ {description} ({module_name})") - test_results["modules"][module_name] = True - except ImportError as e: - print(f"✗ {description} ({module_name}): {e}") - test_results["modules"][module_name] = False - all_passed = False - - # Test enhanced modules (optional) - print("\nEnhanced modules (optional):") - for module_name, description in enhanced_modules: - try: - __import__(module_name) - print(f"✓ {description} ({module_name})") - test_results["modules"][module_name] = True - except ImportError: - print(f"○ {description} ({module_name}) - not available") - test_results["modules"][module_name] = None - - return all_passed - - -def test_core_functions(): - """Test core library functions""" - print_header("CORE FUNCTION TEST") - - try: - from masking_lib import ( - DynamicObfuscator, - ProtocolMimicry, - TrafficProfile, - build_obfuscator, - parse_profile, - stream_generator, - ) - - tests_passed = 0 - tests_total = 0 - - # Test 1: Profile parsing - tests_total += 1 - try: - profile = parse_profile("mixed") - assert profile == TrafficProfile.MIXED - print("✓ Profile parsing works") - tests_passed += 1 - except Exception as e: - print(f"✗ Profile parsing failed: {e}") - - # Test 2: Pattern generation - tests_total += 1 - try: - patterns = ProtocolMimicry.for_profile(TrafficProfile.MIXED) - assert len(patterns) > 0 - print(f"✓ Pattern generation works ({len(patterns)} patterns)") - tests_passed += 1 - except Exception as e: - print(f"✗ Pattern generation failed: {e}") - - # Test 3: Obfuscator creation - tests_total += 1 - try: - obf = DynamicObfuscator() - test_data = b"test packet data" - fragments, delay = obf.obfuscate(test_data) - assert len(fragments) > 0 - assert delay >= 0 - print(f"✓ Obfuscator works ({len(fragments)} fragments)") - tests_passed += 1 - except Exception as e: - print(f"✗ Obfuscator failed: {e}") - - # Test 4: Stream generator - tests_total += 1 - try: - gen = stream_generator(TrafficProfile.MIXED, target_mbps=1.0) - fragments, delay = next(gen) - assert len(fragments) > 0 - assert delay > 0 - print(f"✓ Stream generator works") - tests_passed += 1 - except Exception as e: - print(f"✗ Stream generator failed: {e}") - - # Test 5: Floating rate generator - tests_total += 1 - try: - gen = stream_generator(TrafficProfile.MIXED, min_mbps=1.0, max_mbps=5.0) - fragments, delay = next(gen) - assert len(fragments) > 0 - assert delay > 0 - print(f"✓ Floating rate generator works") - tests_passed += 1 - except Exception as e: - print(f"✗ Floating rate generator failed: {e}") - - print(f"\nCore tests: {tests_passed}/{tests_total} passed") - test_results["modules"]["core_functions"] = tests_passed == tests_total - return tests_passed == tests_total - - except ImportError as e: - print(f"✗ Cannot test core functions: {e}") - test_results["modules"]["core_functions"] = False - return False - - -def test_network_connectivity(): - """Test basic network connectivity""" - print_header("NETWORK CONNECTIVITY TEST") - - try: - # Test UDP socket creation - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(("127.0.0.1", 0)) - port = sock.getsockname()[1] - print(f"✓ UDP socket creation works (bound to port {port})") - - # Test loopback send/receive - def receiver(): - data, addr = sock.recvfrom(1024) - test_results["modules"]["loopback"] = data == b"TEST" - - recv_thread = threading.Thread(target=receiver) - recv_thread.daemon = True - recv_thread.start() - - time.sleep(0.1) - sock.sendto(b"TEST", ("127.0.0.1", port)) - recv_thread.join(timeout=1) - - sock.close() - - if test_results["modules"].get("loopback"): - print("✓ Loopback communication works") - return True - else: - print("✗ Loopback communication failed") - return False - - except Exception as e: - print(f"✗ Network test failed: {e}") - return False - - -def run_transmission_test(duration=30, show_output=False): - """Run full system test with real data transmission""" - print_header(f"TRANSMISSION TEST ({duration} seconds)") - - # Paths for log files - server_log_path = "server_test.log" - client_log_path = "client_test.log" - - server_proc = None - client_proc = None - - try: - # Start server - print("[TEST] Starting server with floating rate (2-8 Mbps)...") - if show_output: - server_proc = subprocess.Popen( - [ - sys.executable, - "traffic_masking_server.py", - "--port", - "8888", - "--min-mbps", - "2", - "--max-mbps", - "8", - "--advanced", - "--profile", - "mixed", - "--stats-interval", - "2", - ] - ) - else: - with open(server_log_path, "w") as server_log: - server_proc = subprocess.Popen( - [ - sys.executable, - "traffic_masking_server.py", - "--port", - "8888", - "--min-mbps", - "2", - "--max-mbps", - "8", - "--advanced", - "--profile", - "mixed", - "--stats-interval", - "2", - ], - stdout=server_log, - stderr=subprocess.STDOUT, - ) - - # Wait for server to start - time.sleep(2) - - if server_proc.poll() is not None: - print("[ERROR] Server failed to start") - test_results["transmission"]["server_started"] = False - return False - - print("[TEST] Server started successfully") - test_results["transmission"]["server_started"] = True - - # Start client - print("[TEST] Starting client with 30% response ratio...") - if show_output: - client_proc = subprocess.Popen( - [ - sys.executable, - "traffic_masking_client.py", - "--server", - "127.0.0.1", - "--port", - "8888", - "--response", - "0.3", - "--advanced", - "--uplink-profile", - "mixed", - "--stats-interval", - "2", - ] - ) - else: - with open(client_log_path, "w") as client_log: - client_proc = subprocess.Popen( - [ - sys.executable, - "traffic_masking_client.py", - "--server", - "127.0.0.1", - "--port", - "8888", - "--response", - "0.3", - "--advanced", - "--uplink-profile", - "mixed", - "--stats-interval", - "2", - ], - stdout=client_log, - stderr=subprocess.STDOUT, - ) - - # Wait for client to connect - time.sleep(2) - - if client_proc.poll() is not None: - print("[ERROR] Client failed to start") - test_results["transmission"]["client_started"] = False - return False - - print("[TEST] Client connected successfully") - test_results["transmission"]["client_started"] = True - - # Monitor transmission - print(f"[TEST] Running transmission test...") - if not show_output: - for i in range(duration): - time.sleep(1) - progress = (i + 1) / duration * 100 - print( - f"[TEST] Progress: {progress:.0f}% ({i + 1}/{duration}s)", end="\r" - ) - - # Check processes are still running - if server_proc.poll() is not None: - print("\n[WARNING] Server stopped unexpectedly") - break - if client_proc.poll() is not None: - print("\n[WARNING] Client stopped unexpectedly") - break - print() # New line after progress - else: - print(f"[TEST] Waiting {duration} seconds... (Press Ctrl+C to stop early)") - try: - time.sleep(duration) - except KeyboardInterrupt: - print("\n[TEST] Interrupted by user") - - # Stop processes - print("[TEST] Stopping client...") - if client_proc: - client_proc.terminate() - try: - client_proc.wait(timeout=2) - except subprocess.TimeoutExpired: - client_proc.kill() - client_proc.wait() - - print("[TEST] Stopping server...") - if server_proc: - server_proc.terminate() - try: - server_proc.wait(timeout=2) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait() - - # Analyze logs if not showing output - if not show_output: - return analyze_transmission_logs(server_log_path, client_log_path) - else: - print("\n[TEST] Transmission test completed (manual verification required)") - return True - - except Exception as e: - print(f"\n[ERROR] Transmission test failed: {e}") - # Cleanup - if server_proc and server_proc.poll() is None: - server_proc.kill() - if client_proc and client_proc.poll() is None: - client_proc.kill() - return False - finally: - # Ensure processes are terminated - if server_proc and server_proc.poll() is None: - server_proc.terminate() - if client_proc and client_proc.poll() is None: - client_proc.terminate() - - -def analyze_transmission_logs(server_log, client_log): - """Analyze logs to verify transmission""" - print("\n[TEST] Analyzing transmission logs...") - - def extract_stats(line): - """Extract statistics from log line""" - stats = {} - - # Extract rate in Mbps - rate_match = re.search(r"Rate:\s*([0-9.]+)\s*Mbps", line) - if rate_match: - stats["rate"] = float(rate_match.group(1)) - - # Extract Rx/Tx rates for client - rx_match = re.search(r"Rx:\s*([0-9.]+)\s*Mbps", line) - tx_match = re.search(r"Tx:\s*([0-9.]+)\s*Mbps", line) - if rx_match: - stats["rx"] = float(rx_match.group(1)) - if tx_match: - stats["tx"] = float(tx_match.group(1)) - - # Extract client count - clients_match = re.search(r"Clients:\s*(\d+)", line) - if clients_match: - stats["clients"] = int(clients_match.group(1)) - - return stats - - server_stats = [] - client_stats = [] - - # Parse server log - try: - with open(server_log, "r") as f: - for line in f: - if "[STATS]" in line: - stats = extract_stats(line) - if stats: - server_stats.append(stats) - except Exception as e: - print(f"[WARNING] Could not parse server log: {e}") - - # Parse client log - try: - with open(client_log, "r") as f: - for line in f: - if "[STATS]" in line: - stats = extract_stats(line) - if stats: - client_stats.append(stats) - except Exception as e: - print(f"[WARNING] Could not parse client log: {e}") - - # Analyze results - success = True - - if server_stats: - rates = [s.get("rate", 0) for s in server_stats if "rate" in s] - if rates: - avg_rate = sum(rates) / len(rates) - min_rate = min(rates) - max_rate = max(rates) - print( - f"✓ Server: Avg={avg_rate:.2f} Mbps, Min={min_rate:.2f} Mbps, Max={max_rate:.2f} Mbps" - ) - test_results["transmission"]["server_rate"] = avg_rate - - # Check if rate is within expected floating range (2-8 Mbps) - if min_rate >= 1.0 and max_rate <= 10.0: - print(f"✓ Server rate within expected range") - else: - print(f"⚠ Server rate outside expected range") - - # Check client connections - clients = [s.get("clients", 0) for s in server_stats if "clients" in s] - if clients and max(clients) > 0: - print(f"✓ Server had {max(clients)} client(s) connected") - test_results["transmission"]["clients_connected"] = True - else: - print(f"✗ No clients connected to server") - test_results["transmission"]["clients_connected"] = False - success = False - else: - print("✗ No server statistics found") - success = False - - if client_stats: - rx_rates = [s.get("rx", 0) for s in client_stats if "rx" in s] - tx_rates = [s.get("tx", 0) for s in client_stats if "tx" in s] - - if rx_rates: - avg_rx = sum(rx_rates) / len(rx_rates) - print(f"✓ Client Rx: {avg_rx:.2f} Mbps average") - test_results["transmission"]["client_rx"] = avg_rx - - if avg_rx > 0.5: - print(f"✓ Client receiving data successfully") - else: - print(f"✗ Client receive rate too low") - success = False - - if tx_rates: - avg_tx = sum(tx_rates) / len(tx_rates) - print(f"✓ Client Tx: {avg_tx:.2f} Mbps average") - test_results["transmission"]["client_tx"] = avg_tx - - if avg_tx > 0.1: - print(f"✓ Client transmitting response traffic") - else: - print(f"⚠ Client transmit rate low") - else: - print("✗ No client statistics found") - success = False - - return success - - -def run_reconnection_test(port=8889): - """Test that client reconnects after server restart""" - print_header("RECONNECTION TEST (server restart)") - - server_proc = None - client_proc = None - client_log_path = "client_reconnect_test.log" - - def start_server(): - return subprocess.Popen( - [ - sys.executable, - "traffic_masking_server.py", - "--port", - str(port), - "--min-mbps", - "2", - "--max-mbps", - "4", - "--advanced", - "--profile", - "mixed", - "--stats-interval", - "1", - ], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - - try: - # Phase 1: Start server and client, verify initial connection - print("[RECONN] Phase 1: Starting server and client...") - server_proc = start_server() - time.sleep(2) - - if server_proc.poll() is not None: - print("✗ Server failed to start") - test_results["transmission"]["reconnect"] = False - return False - - client_log = open(client_log_path, "w") - client_proc = subprocess.Popen( - [ - sys.executable, - "traffic_masking_client.py", - "--server", - "127.0.0.1", - "--port", - str(port), - "--response", - "0.3", - "--stats-interval", - "2", - ], - stdout=client_log, - stderr=subprocess.STDOUT, - ) - - # Wait for traffic to flow - print("[RECONN] Waiting 8s for initial traffic flow...") - time.sleep(8) - - if client_proc.poll() is not None: - print("✗ Client died during initial connection") - test_results["transmission"]["reconnect"] = False - return False - - # Check client log for receiving data - client_log.flush() - with open(client_log_path, "r") as f: - initial_log = f.read() - - if "Rx:" not in initial_log: - print("✗ Client did not receive any data in Phase 1") - test_results["transmission"]["reconnect"] = False - return False - - print("✓ Phase 1: Client connected and receiving data") - - # Phase 2: Kill server, verify client detects loss - print("[RECONN] Phase 2: Killing server...") - server_proc.terminate() - try: - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - server_proc.wait() - server_proc = None - - print("[RECONN] Waiting 15s for client to detect connection loss...") - time.sleep(15) - - if client_proc.poll() is not None: - print("✗ Client process died after server stop") - test_results["transmission"]["reconnect"] = False - return False - - client_log.flush() - with open(client_log_path, "r") as f: - loss_log = f.read() - - if "Connection lost" in loss_log: - print("✓ Phase 2: Client detected connection loss") - else: - print("⚠ Phase 2: Client may not have detected loss (checking Phase 3)") - - # Verify client does NOT falsely report successful reconnection while server is down - # Split log into lines after "Connection lost" to check behavior during downtime - loss_idx = loss_log.find("Connection lost") - if loss_idx >= 0: - downtime_log = loss_log[loss_idx:] - false_reconnects = downtime_log.count("Reconnected successfully") - if false_reconnects > 0: - print( - f"✗ Phase 2: Client falsely reported 'Reconnected successfully' {false_reconnects} time(s) while server was down" - ) - test_results["transmission"]["reconnect"] = False - return False - else: - print("✓ Phase 2: No false reconnection reports during server downtime") - - # Verify client shows disconnected status (not connected) during downtime - downtime_stats = [l for l in downtime_log.splitlines() if "[STATS]" in l] - false_connected = [l for l in downtime_stats if "Status: connected" in l] - if false_connected: - print( - f"✗ Phase 2: Client reported 'connected' status {len(false_connected)} time(s) while server was down" - ) - test_results["transmission"]["reconnect"] = False - return False - else: - print( - "✓ Phase 2: Client correctly reported disconnected status during downtime" - ) - - # Phase 3: Restart server, verify client reconnects and resumes traffic - print("[RECONN] Phase 3: Restarting server...") - server_proc = start_server() - time.sleep(2) - - if server_proc.poll() is not None: - print("✗ Server failed to restart") - test_results["transmission"]["reconnect"] = False - return False - - print("[RECONN] Waiting 20s for client to reconnect and resume traffic...") - time.sleep(20) - - if client_proc.poll() is not None: - print("✗ Client process died after server restart") - test_results["transmission"]["reconnect"] = False - return False - - # Analyze client log for recovery - client_log.flush() - with open(client_log_path, "r") as f: - full_log = f.read() - - # Find stats lines after server restart (last ~10 lines with STATS) - stats_lines = [l for l in full_log.splitlines() if "[STATS]" in l] - - if len(stats_lines) < 3: - print("✗ Not enough stats data to verify reconnection") - test_results["transmission"]["reconnect"] = False - return False - - # Verify "Reconnected successfully" appears after server restart - # (should only appear now, not during Phase 2 downtime) - has_reconnected_msg = "Reconnected successfully" in full_log - if has_reconnected_msg: - print("✓ Phase 3: Client reported successful reconnection") - else: - print("✗ Phase 3: Client never reported 'Reconnected successfully'") - test_results["transmission"]["reconnect"] = False - return False - - # Check the last few stats lines show connected status and data flow - last_stats = stats_lines[-3:] - has_connected_status = any("Status: connected" in l for l in last_stats) - recovered = False - for line in last_stats: - rx_match = re.search(r"Rx:\s*([0-9.]+)\s*Mbps", line) - if rx_match and float(rx_match.group(1)) > 0.1: - recovered = True - break - - if has_connected_status and recovered: - print("✓ Phase 3: Client connected and receiving data after recovery") - test_results["transmission"]["reconnect"] = True - success = True - elif has_connected_status: - print("✓ Phase 3: Client connected (traffic still ramping up)") - test_results["transmission"]["reconnect"] = True - success = True - elif recovered: - print("✓ Phase 3: Client receiving data after recovery") - test_results["transmission"]["reconnect"] = True - success = True - else: - print("✗ Phase 3: Client did NOT recover after server restart") - test_results["transmission"]["reconnect"] = False - success = False - - # Print relevant log excerpts - print("\n[RECONN] Key log entries:") - for line in full_log.splitlines(): - if any( - kw in line - for kw in [ - "Registration", - "Reconnect", - "Connection lost", - "disconnected", - ] - ): - print(f" {line.strip()}") - - return success - - except Exception as e: - print(f"\n[ERROR] Reconnection test failed: {e}") - import traceback - - traceback.print_exc() - test_results["transmission"]["reconnect"] = False - return False - finally: - if client_proc and client_proc.poll() is None: - client_proc.terminate() - try: - client_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - client_proc.kill() - if server_proc and server_proc.poll() is None: - server_proc.terminate() - try: - server_proc.wait(timeout=3) - except subprocess.TimeoutExpired: - server_proc.kill() - try: - client_log.close() - except Exception: - pass - - -def print_summary(): - """Print test summary""" - print_header("TEST SUMMARY") - - total_tests = 0 - passed_tests = 0 - - # Count module tests - for module, result in test_results["modules"].items(): - if result is not None: # Skip optional modules - total_tests += 1 - if result: - passed_tests += 1 - - # Count transmission tests - for test, result in test_results["transmission"].items(): - if isinstance(result, bool): - total_tests += 1 - if result: - passed_tests += 1 - - print(f"Total tests run: {total_tests}") - print(f"Tests passed: {passed_tests}") - print(f"Tests failed: {total_tests - passed_tests}") - print( - f"Success rate: {(passed_tests / total_tests * 100) if total_tests > 0 else 0:.1f}%" - ) - - if passed_tests == total_tests: - print("\n🎉 ALL TESTS PASSED! The Traffic Masking System is working correctly.") - elif passed_tests > total_tests * 0.7: - print("\n✓ Most tests passed. The system is mostly functional.") - else: - print("\n⚠ Several tests failed. Please check the issues above.") - - return passed_tests == total_tests - - -def main(): - """Main test runner""" - parser = argparse.ArgumentParser(description="Test Traffic Masking System") - parser.add_argument( - "--duration", - type=int, - default=30, - help="Transmission test duration in seconds (default: 30)", - ) - parser.add_argument( - "--quick", - action="store_true", - help="Run quick tests only (skip long transmission test)", - ) - parser.add_argument( - "--output", - action="store_true", - help="Show server and client output during transmission test", - ) - parser.add_argument( - "--modules-only", - action="store_true", - help="Test modules only, skip transmission test", - ) - - args = parser.parse_args() - - print("=" * 60) - print(" TRAFFIC MASKING SYSTEM - COMPLETE TEST SUITE") - print("=" * 60) - - try: - # Run module tests - modules_ok = test_module_imports() - if modules_ok: - core_ok = test_core_functions() - network_ok = test_network_connectivity() - else: - print("\n[ERROR] Core modules failed to import, skipping other tests") - return 1 - - # Run transmission test unless skipped - if not args.modules_only: - if args.quick: - transmission_ok = run_transmission_test( - duration=10, show_output=args.output - ) - else: - transmission_ok = run_transmission_test( - duration=args.duration, show_output=args.output - ) - - # Run reconnection test - reconnect_ok = run_reconnection_test() - else: - print("\n[INFO] Skipping transmission test (--modules-only)") - transmission_ok = None - - # Print summary - all_passed = print_summary() - - return 0 if all_passed else 1 - - except KeyboardInterrupt: - print("\n\n[TEST] Test suite interrupted by user") - return 1 - except Exception as e: - print(f"\n[ERROR] Test suite failed: {e}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) From 9d397670fa2b27ec9454983e0e1a0d063c051d4d Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:11:32 +0300 Subject: [PATCH 02/10] traffic-masking: rate correctness, input validation, hot-path safety - Unify the bit-rate unit as decimal Mbps (10^6 bit/s) of application payload bytes across server, client and generator via mbps_to_bytes_per_second; drop the binary 1024*1024 math and the 1.1 fudge factor that inflated --mbps 1 to ~8.8 Mbit/s. - Pace the server on the monotonic clock and cap an idle (no-client) gap to a single scheduling tick so a newly connected client cannot receive a burst of accumulated credit. - Replace per-byte Python RNG payload generation with os.urandom on both send paths; stop reseeding the global RNG in the hot path (it made same-size payloads identical within a window and corrupted the shared random stream). Guard size-minus-header math at zero. - Validate server/client constructor inputs (positive rates/intervals, both-or-neither min/max with min < max, response and entropy in [0,1], positive mtu); surface CLI errors through parser.error (exit code 2). - Change the client --response default to 0.0 so the flow stays download-dominant unless uplink is explicitly requested. - Add unit tests for the rate conversion, idle-budget cap, payload non-determinism and RNG isolation, and input validation; add live characterization that --mbps 1 is no longer inflated. - Complete requirements-dev.txt as a real pip freeze (add coverage, iniconfig, packaging, pluggy, Pygments transitive pins). --- traffic-masking/masking_lib.py | 14 ++- traffic-masking/requirements-dev.txt | 5 + traffic-masking/test_cli.py | 73 ++++++++++++++ traffic-masking/test_live.py | 31 ++++++ traffic-masking/test_payload.py | 43 +++++++++ traffic-masking/test_rate.py | 53 ++++++++++ traffic-masking/traffic_masking_client.py | 59 ++++++++---- traffic-masking/traffic_masking_server.py | 112 +++++++++++++++------- 8 files changed, 334 insertions(+), 56 deletions(-) create mode 100644 traffic-masking/test_cli.py create mode 100644 traffic-masking/test_payload.py create mode 100644 traffic-masking/test_rate.py diff --git a/traffic-masking/masking_lib.py b/traffic-masking/masking_lib.py index bd4b52b..e39ce2d 100644 --- a/traffic-masking/masking_lib.py +++ b/traffic-masking/masking_lib.py @@ -57,8 +57,18 @@ "build_obfuscator", "init_udp_socket", "send_fragments", + "mbps_to_bytes_per_second", ] +# Bit-rate unit is decimal megabits/s (10^6 bit/s) of application payload bytes, +# used consistently across configuration, pacing and metrics. +_BITS_PER_MEGABIT = 1_000_000 + + +def mbps_to_bytes_per_second(mbps: float) -> float: + """Convert a decimal-Mbps rate to application bytes per second.""" + return float(mbps) * _BITS_PER_MEGABIT / 8 + class TrafficProfile(Enum): WEB_BROWSING = "web" @@ -640,8 +650,8 @@ def stream_generator( rate_limit_mbps = None if rate_limit_mbps and rate_limit_mbps > 0: - # Target bytes per second for desired rate - target_bytes_per_second = rate_limit_mbps * 1024 * 1024 / 8 + # Target bytes per second for desired rate (decimal Mbps) + target_bytes_per_second = mbps_to_bytes_per_second(rate_limit_mbps) # Simple and direct delay calculation for better rate achievement if target_bytes_per_second > 0 and packet_bytes > 0: diff --git a/traffic-masking/requirements-dev.txt b/traffic-masking/requirements-dev.txt index f3b6533..5779f85 100644 --- a/traffic-masking/requirements-dev.txt +++ b/traffic-masking/requirements-dev.txt @@ -1,3 +1,8 @@ +coverage==7.15.1 +iniconfig==2.3.0 +packaging==26.2 +pluggy==1.6.0 +Pygments==2.20.0 pytest==9.1.1 pytest-cov==7.1.0 ruff==0.15.21 diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py new file mode 100644 index 0000000..f547a59 --- /dev/null +++ b/traffic-masking/test_cli.py @@ -0,0 +1,73 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Input validation: bad config raises ValueError; bad CLI exits with code 2.""" + +import subprocess +import sys + +import pytest + +from conftest import CLIENT, SERVER +from traffic_masking_client import AdaptiveTrafficClient +from traffic_masking_server import MaskingTrafficServer + + +@pytest.mark.parametrize( + "kwargs", + [ + {"target_mbps": 0}, + {"target_mbps": -1}, + {"min_mbps": 5, "max_mbps": 2}, # min >= max + {"min_mbps": 2}, # only one of the pair + {"max_mbps": 8}, # only one of the pair + {"target_mbps": 5, "mtu": 0}, + {"target_mbps": 5, "entropy": 1.5}, + {"target_mbps": 5, "stats_interval": 0}, + ], +) +def test_server_rejects_bad_config(kwargs): + with pytest.raises(ValueError): + MaskingTrafficServer(**kwargs) + + +def test_server_accepts_valid_floating_config(): + server = MaskingTrafficServer(min_mbps=2, max_mbps=8) + # 5 Mbps midpoint -> 625_000 bytes/s (decimal). + assert server.target_bytes_per_second == 625_000 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"response_ratio": 1.5}, + {"response_ratio": -0.1}, + {"entropy": 2.0}, + {"mtu": 0}, + {"stats_interval": -1}, + ], +) +def test_client_rejects_bad_config(kwargs): + with pytest.raises(ValueError): + AdaptiveTrafficClient("127.0.0.1", 8888, **kwargs) + + +def _run(script, *args): + return subprocess.run( + [sys.executable, script, *args], + capture_output=True, + text=True, + timeout=15, + ) + + +def test_server_cli_bad_rate_exits_2(): + assert _run(SERVER, "--mbps", "0").returncode == 2 + + +def test_server_cli_partial_range_exits_2(): + assert _run(SERVER, "--min-mbps", "2").returncode == 2 + + +def test_client_cli_bad_response_exits_2(): + assert _run(CLIENT, "--server", "127.0.0.1", "--response", "2").returncode == 2 diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index 6ef4b25..d081a66 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -84,6 +84,37 @@ def test_reconnection_after_server_restart(spawn): assert wait_for(clog, "Reconnected successfully", 25.0), read_log(clog) +def test_fixed_rate_is_not_inflated(spawn): + """Characterization: --mbps 1 emits on the order of 1 Mbit/s, not ~8.8. + + The legacy pattern generator legitimately scales the commanded rate + (bursts up to 4x for single windows), so this only pins the gross unit + error: the old bits-as-bytes budget inflated the average ~8.8x. + """ + port = free_udp_port() + _server, slog = spawn( + SERVER, + ["--host", "127.0.0.1", "--port", str(port), "--mbps", "1", + "--stats-interval", "1"], + "server", + ) + assert wait_for(slog, "started", 5.0), read_log(slog) + + _client, clog = spawn( + CLIENT, + ["--server", "127.0.0.1", "--port", str(port), "--stats-interval", "1"], + "client", + ) + assert wait_for(clog, "Rx:", 10.0), read_log(clog) + + time.sleep(6) + rates = [float(m) for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(slog))] + assert rates, read_log(slog) + mean_rate = sum(rates) / len(rates) + assert 0.1 <= mean_rate <= 3.0, rates + assert max(rates) <= 5.0, rates + + def test_floating_rate_stays_within_bounds(spawn): """Characterization: the emitted server rate stays within a slack of [min,max]. diff --git a/traffic-masking/test_payload.py b/traffic-masking/test_payload.py new file mode 100644 index 0000000..99f49cb --- /dev/null +++ b/traffic-masking/test_payload.py @@ -0,0 +1,43 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Payload generation is non-deterministic and does not touch global RNG state.""" + +import random + +from traffic_masking_client import AdaptiveTrafficClient +from traffic_masking_server import PacketGenerator + + +def test_server_payload_is_not_deterministic(): + gen = PacketGenerator() + p1 = gen.generate_packet(600) + p2 = gen.generate_packet(600) + assert len(p1) == 600 and len(p2) == 600 + # Payload region follows the 28-byte header (seq 4 + ts 8 + md5 16). + assert p1[28:] != p2[28:] + + +def test_generate_packet_does_not_mutate_global_rng(): + gen = PacketGenerator() + random.seed(1234) + before = random.random() + random.seed(1234) + gen.generate_packet(600) + after = random.random() + assert before == after + + +def test_generate_packet_guards_small_sizes(): + gen = PacketGenerator() + # Smaller than the 28-byte header must not raise or produce negative sizes. + pkt = gen.generate_packet(1) + assert len(pkt) >= 28 + + +def test_client_response_payload_is_not_deterministic(): + client = AdaptiveTrafficClient("127.0.0.1", 9) # no socket opened in __init__ + p1 = client.generate_response_packet(600) + p2 = client.generate_response_packet(600) + # Header is [type 1][seq 4][ts 8] = 13 bytes; the payload after must differ. + assert p1[13:] != p2[13:] diff --git a/traffic-masking/test_rate.py b/traffic-masking/test_rate.py new file mode 100644 index 0000000..a81917a --- /dev/null +++ b/traffic-masking/test_rate.py @@ -0,0 +1,53 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Rate correctness: decimal-Mbps conversion and pacing budget accounting.""" + +from masking_lib import mbps_to_bytes_per_second +from traffic_masking_server import MaskingTrafficServer, _budget_bytes + + +def test_mbps_to_bytes_per_second_is_decimal(): + assert mbps_to_bytes_per_second(1) == 125_000 + assert mbps_to_bytes_per_second(8) == 1_000_000 + + +def test_server_target_uses_decimal_conversion(): + # The old loop budgeted `mbps * 1024 * 1024` *bits* as if they were bytes + # and added a 1.1 fudge factor, inflating --mbps 1 to ~8.8 Mbit/s of + # payload. The stored target must be plain decimal bytes per second. + server = MaskingTrafficServer(target_mbps=1) + assert server.target_bytes_per_second == 125_000 + legacy_bytes_budget = 1 * 1024 * 1024 * 1.1 # what the old loop granted + assert legacy_bytes_budget / server.target_bytes_per_second > 8 + + +def test_fixed_and_floating_conversions_are_identical(): + fixed = MaskingTrafficServer(target_mbps=5) + floating = MaskingTrafficServer(min_mbps=2, max_mbps=8) # midpoint 5 + assert fixed.target_bytes_per_second == floating.target_bytes_per_second + + +def test_budget_zero_for_nonpositive_inputs(): + assert _budget_bytes(0, 1.0) == 0 + assert _budget_bytes(-5, 1.0) == 0 + assert _budget_bytes(125_000, 0.0) == 0 + assert _budget_bytes(125_000, -1.0) == 0 + + +def test_budget_caps_idle_gap_to_one_tick(): + # A long idle gap must not turn into accumulated credit: at most one + # scheduling tick of bytes is granted no matter how much time passed. + rate = 125_000 # 1 Mbps + assert _budget_bytes(rate, 60.0) == _budget_bytes(rate, 0.5) + assert _budget_bytes(rate, 3600.0) == _budget_bytes(rate, 0.5) + + +def test_budget_tracks_configured_rate_over_fake_clock_window(): + # Simulate the pacing loop over a fake one-second window of 10 ms ticks + # at a constant commanded rate; the granted budget must stay within ±15% + # of the configured decimal rate (exact modulo integer truncation). + rate = mbps_to_bytes_per_second(1) # 125_000 bytes/s + ticks = 100 + granted = sum(_budget_bytes(rate, 0.01) for _ in range(ticks)) + assert abs(granted - 125_000) <= 125_000 * 0.15 diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index 8b3bce4..f1af620 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -10,6 +10,7 @@ """ import argparse +import os import random import socket import struct @@ -21,6 +22,7 @@ ObfuscationConfig, build_obfuscator, init_udp_socket, + mbps_to_bytes_per_second, parse_profile, send_fragments, ) @@ -47,6 +49,16 @@ def __init__( entropy=1.0, stats_interval=5.0, ): + # Validate configuration up front; fail fast on invalid inputs. + if not (0.0 <= float(response_ratio) <= 1.0): + raise ValueError("response ratio must be in [0.0, 1.0]") + if not (0.0 <= float(entropy) <= 1.0): + raise ValueError("entropy must be in [0.0, 1.0]") + if int(mtu) <= 0: + raise ValueError("mtu must be positive") + if float(stats_interval) <= 0: + raise ValueError("stats-interval must be positive") + self.server_host = server_host self.server_port = server_port self.server_addr = (server_host, server_port) @@ -185,8 +197,8 @@ def generate_response_packet(self, size=None): header_size = 1 + 4 + 8 data_size = max(0, size - header_size) - # Generate data with entropy - random_data = bytes([random.randint(0, 255) for _ in range(data_size)]) + # Bulk CSPRNG payload (no per-byte Python RNG in the hot path). + random_data = os.urandom(data_size) return packet_type + seq_bytes + timestamp + random_data @@ -232,7 +244,7 @@ def receive_loop(self): window_bytes += len(data) current_time = time.time() if current_time - window_start >= 1.0: # 1 second window - self.received_rate = window_bytes * 8 / 1024 / 1024 # Mbps + self.received_rate = window_bytes * 8 / 1_000_000 # decimal Mbps self.rate_window.append(self.received_rate) if len(self.rate_window) > 10: self.rate_window.pop(0) @@ -285,8 +297,8 @@ def send_loop(self): # Adaptive generation based on received traffic if self.received_rate > 0: # Send percentage of received rate - target_send_rate = ( - self.received_rate * self.response_ratio * 1024 * 1024 / 8 + target_send_rate = mbps_to_bytes_per_second( + self.received_rate * self.response_ratio ) # bytes/sec # Add random bursts @@ -311,8 +323,8 @@ def stats_loop(self): time.sleep(self.stats_interval) elapsed = time.time() - self.stats["start_time"] if elapsed > 0: - recv_mbps = (self.stats["bytes_received"] * 8) / (elapsed * 1024 * 1024) - send_mbps = (self.stats["bytes_sent"] * 8) / (elapsed * 1024 * 1024) + recv_mbps = (self.stats["bytes_received"] * 8) / (elapsed * 1_000_000) + send_mbps = (self.stats["bytes_sent"] * 8) / (elapsed * 1_000_000) recv_pps = self.stats["packets_received"] / elapsed send_pps = self.stats["packets_sent"] / elapsed @@ -339,7 +351,11 @@ def main(): parser.add_argument("--server", required=True, help="Server IP address") parser.add_argument("--port", type=int, default=8888, help="Server UDP port") parser.add_argument( - "--response", type=float, default=0.3, help="Uplink response ratio (0.0-1.0)" + "--response", + type=float, + default=0.0, + help="Uplink response ratio (0.0-1.0); default 0.0 keeps the flow " + "download-dominant. Non-zero uplink is an explicit choice.", ) parser.add_argument( "--advanced", @@ -382,18 +398,21 @@ def main(): args = parser.parse_args() - client = AdaptiveTrafficClient( - args.server, - args.port, - args.response, - advanced=args.advanced, - uplink_profile=args.uplink_profile, - header=args.header, - padding=args.padding, - mtu=args.mtu, - entropy=args.entropy, - stats_interval=args.stats_interval, - ) + try: + client = AdaptiveTrafficClient( + args.server, + args.port, + args.response, + advanced=args.advanced, + uplink_profile=args.uplink_profile, + header=args.header, + padding=args.padding, + mtu=args.mtu, + entropy=args.entropy, + stats_interval=args.stats_interval, + ) + except ValueError as exc: + parser.error(str(exc)) try: client.connect() diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index 4dc6d19..be03927 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -11,6 +11,7 @@ import argparse import hashlib +import os import random import socket import struct @@ -18,7 +19,23 @@ import time import numpy as np -from masking_lib import DynamicObfuscator, TrafficProfile, stream_generator +from masking_lib import ( + DynamicObfuscator, + TrafficProfile, + mbps_to_bytes_per_second, + stream_generator, +) + + +def _budget_bytes(rate_bytes_per_second, elapsed, max_tick=0.5): + """Bytes allowed to send over ``elapsed`` seconds at the given byte rate. + + ``elapsed`` is clamped to ``max_tick`` so an idle gap (no clients) cannot + accumulate a burst of credit that floods the next client to connect. + """ + if rate_bytes_per_second <= 0 or elapsed <= 0: + return 0 + return int(rate_bytes_per_second * min(elapsed, max_tick)) class TrafficPattern: @@ -116,12 +133,11 @@ def generate_packet(self, target_size=None): timestamp = struct.pack("!Q", int(time.time() * 1000000)) # microseconds seq_bytes = struct.pack("!I", self.sequence) - # Pseudo-random payload with a time-based pattern - pattern_seed = int(time.time() / 10) # changes every 10 seconds - random.seed(pattern_seed) - data_size = size - 28 # 4 + 8 + 16 = 28 bytes header - random_data = bytes([random.randint(0, 255) for _ in range(data_size)]) - random.seed() # reset seed + # Random payload from a bulk CSPRNG source. Never reseed the global RNG + # in the hot path: it made same-size payloads identical within a window + # and corrupted the shared random stream used by other threads. + data_size = max(0, size - 28) # 4 + 8 + 16 = 28 bytes header + random_data = os.urandom(data_size) # Calculate checksum packet_content = seq_bytes + timestamp + random_data @@ -148,16 +164,34 @@ def __init__( entropy=1.0, stats_interval=5.0, ): + # Validate configuration up front; fail fast on invalid rates/ranges. + floating = min_mbps is not None and max_mbps is not None + if (min_mbps is None) != (max_mbps is None): + raise ValueError("min-mbps and max-mbps must be given together") + if floating: + if min_mbps <= 0 or max_mbps <= 0: + raise ValueError("min-mbps and max-mbps must be positive") + if min_mbps >= max_mbps: + raise ValueError("min-mbps must be less than max-mbps") + elif target_mbps is None or target_mbps <= 0: + raise ValueError("target rate (--mbps) must be positive") + if int(mtu) <= 0: + raise ValueError("mtu must be positive") + if not (0.0 <= float(entropy) <= 1.0): + raise ValueError("entropy must be in [0.0, 1.0]") + if float(stats_interval) <= 0: + raise ValueError("stats-interval must be positive") + self.host = host self.port = port self.target_mbps = target_mbps self.min_mbps = min_mbps self.max_mbps = max_mbps - # Use floating rate if min/max specified, otherwise use target - if min_mbps is not None and max_mbps is not None: - self.target_bps = ((min_mbps + max_mbps) / 2) * 1024 * 1024 + # Rate is decimal Mbps of application bytes; store the target in bytes/s. + if floating: + self.target_bytes_per_second = mbps_to_bytes_per_second((min_mbps + max_mbps) / 2) else: - self.target_bps = target_mbps * 1024 * 1024 + self.target_bytes_per_second = mbps_to_bytes_per_second(target_mbps) self.socket = None self.clients = {} # {address: {'last_seen': timestamp, 'stats': {...}}} self.running = False @@ -260,7 +294,9 @@ def receive_loop(self): def send_loop(self): """Send cover traffic to clients""" - last_send_time = time.time() + # Pacing uses the monotonic clock: wall-clock steps (NTP) must not + # produce negative or inflated byte budgets. + last_send_time = time.monotonic() bytes_accumulator = 0 # Rate control for advanced mode @@ -270,6 +306,10 @@ def send_loop(self): while self.running: if not self.clients: time.sleep(0.1) + # No clients: reset pacing so idle time is not billed as a burst + # to the next client that connects. + last_send_time = time.monotonic() + bytes_accumulator = 0 continue # Advanced generator-driven mode with proper rate limiting @@ -319,15 +359,16 @@ def send_loop(self): continue # Legacy accumulator mode (default) - current_time = time.time() + current_time = time.monotonic() elapsed = current_time - last_send_time - # Get current target bitrate - current_rate_bps = self.pattern_gen.get_current_rate(self.target_bps) + # Current target rate in bytes/s (the pattern scales the byte budget). + current_rate_bps = self.pattern_gen.get_current_rate( + self.target_bytes_per_second + ) - # Compute target bytes to send (with buffer for smoother rate) - target_bytes = int(current_rate_bps * elapsed * 1.1) # 10% buffer - bytes_accumulator += target_bytes + # Bytes allowed for this interval; an idle gap is capped to one tick. + bytes_accumulator += _budget_bytes(current_rate_bps, elapsed) # Send packets in batches for efficiency packets_sent_this_round = 0 @@ -392,8 +433,8 @@ def stats_loop(self): packets_delta = self.stats["packets_sent"] - self.last_stats["packets_sent"] if time_delta > 0: - # Instantaneous rate (not cumulative average) - mbps = (bytes_delta * 8) / (time_delta * 1024 * 1024) + # Instantaneous rate (not cumulative average), decimal Mbps + mbps = (bytes_delta * 8) / (time_delta * 1_000_000) pps = packets_delta / time_delta pattern_desc = ( @@ -484,20 +525,23 @@ def main(): args = parser.parse_args() - server = MaskingTrafficServer( - args.host, - args.port, - args.mbps, - min_mbps=args.min_mbps, - max_mbps=args.max_mbps, - advanced=args.advanced, - profile=args.profile, - header=args.header, - padding=args.padding, - mtu=args.mtu, - entropy=args.entropy, - stats_interval=args.stats_interval, - ) + try: + server = MaskingTrafficServer( + args.host, + args.port, + args.mbps, + min_mbps=args.min_mbps, + max_mbps=args.max_mbps, + advanced=args.advanced, + profile=args.profile, + header=args.header, + padding=args.padding, + mtu=args.mtu, + entropy=args.entropy, + stats_interval=args.stats_interval, + ) + except ValueError as exc: + parser.error(str(exc)) try: server.start() From 7951239e3f27b0dfe70cf35b1b27022b194408c0 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:34:33 +0300 Subject: [PATCH 03/10] traffic-masking: complete scaffold and rate review fixes --- traffic-masking/Dockerfile | 2 +- traffic-masking/EXAMPLES.md | 11 +- traffic-masking/README.md | 25 ++-- traffic-masking/conftest.py | 131 ++++++++++++++++--- traffic-masking/masking_lib.py | 2 +- traffic-masking/test_cli.py | 46 +++++-- traffic-masking/test_live.py | 95 +++++++------- traffic-masking/test_payload.py | 30 ++++- traffic-masking/test_rate.py | 66 ++++++++-- traffic-masking/traffic_masking_client.py | 45 ++++--- traffic-masking/traffic_masking_server.py | 147 ++++++++++++++++------ 11 files changed, 440 insertions(+), 160 deletions(-) diff --git a/traffic-masking/Dockerfile b/traffic-masking/Dockerfile index 928c84e..ac383c2 100644 --- a/traffic-masking/Dockerfile +++ b/traffic-masking/Dockerfile @@ -43,4 +43,4 @@ EXPOSE 8888/udp ENTRYPOINT ["python"] # Default command shows usage -CMD ["-c", "print('Usage:\\n Server: python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced\\n Client: python traffic_masking_client.py --server --response 0.3 --advanced')"] +CMD ["-c", "print('Usage:\\n Server: python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced\\n Client: python traffic_masking_client.py --server --advanced')"] diff --git a/traffic-masking/EXAMPLES.md b/traffic-masking/EXAMPLES.md index d39eb31..9968640 100644 --- a/traffic-masking/EXAMPLES.md +++ b/traffic-masking/EXAMPLES.md @@ -3,18 +3,23 @@ ## Quick Start ```bash -# Test the system -python test_traffic_masking.py --quick +# Test the system without live process/network tests +make test-fast # Basic server and client python traffic_masking_server.py --mbps 5 -python traffic_masking_client.py --server 127.0.0.1 --response 0.3 +python traffic_masking_client.py --server 127.0.0.1 # Floating rate (recommended) python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced python traffic_masking_client.py --server 127.0.0.1 --response 0.3 --advanced ``` +Mbps values are decimal Mbit/s of application UDP payload. The client defaults +to no scheduled uplink (`--response 0.0`). A nonzero response is an explicit +diagnostic/profile choice; the current standalone scheduler does not guarantee +that exact ratio on the wire. + ## Use Cases ### Mask Video Calls diff --git a/traffic-masking/README.md b/traffic-masking/README.md index ac4acbb..a9e7045 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -32,7 +32,7 @@ python traffic_masking_server.py --mbps 5 python traffic_masking_server.py --min-mbps 2 --max-mbps 8 # Client -python traffic_masking_client.py --server --response 0.3 +python traffic_masking_client.py --server ``` ### Advanced Mode @@ -45,6 +45,7 @@ python traffic_masking_server.py \ --header rtp --padding random # Client with matching configuration +# A nonzero response is an explicit diagnostic/profile uplink choice. python traffic_masking_client.py \ --server --response 0.3 \ --advanced --uplink-profile mixed @@ -53,23 +54,25 @@ python traffic_masking_client.py \ ## Testing ```bash -# Run complete test suite (includes reconnection test) -python test_traffic_masking.py +# Run fast unit tests +make test-fast -# Quick 10-second test -python test_traffic_masking.py --quick +# Run bounded live process/network tests +make test-live -# Module tests only (no network transmission) -python test_traffic_masking.py --modules-only +# Run the complete pytest suite +make test ``` ## Key Parameters -- `--mbps`: Fixed target rate in Mbps -- `--min-mbps/--max-mbps`: Floating rate range (more realistic) +- `--mbps`: Fixed target rate in decimal Mbps of application UDP payload +- `--min-mbps/--max-mbps`: Floating range in the same decimal Mbps unit - `--advanced`: Enable ML-resistant features - `--profile`: Traffic pattern (web/video/voip/file/gaming/mixed) -- `--response`: Client uplink ratio (0.0-1.0) +- `--response`: Optional diagnostic/profile uplink setting (0.0-1.0, default + 0.0). Nonzero values request additional uplink traffic; the current standalone + scheduler does not guarantee that exact ratio on the wire. - `--header`: Pseudo-headers (none/rtp/quic) - `--padding`: Padding strategy (none/random/fixed_buckets/progressive) - `--entropy`: Payload entropy (0.0-1.0) @@ -93,4 +96,4 @@ See [systemd/](systemd/) directory for service unit files. ## License -Apache-2.0 \ No newline at end of file +Apache-2.0 diff --git a/traffic-masking/conftest.py b/traffic-masking/conftest.py index ba6d1c5..d4f884f 100644 --- a/traffic-masking/conftest.py +++ b/traffic-masking/conftest.py @@ -8,10 +8,13 @@ separate pytest.ini. """ +import os +import signal import socket import subprocess import sys import time +from dataclasses import dataclass from pathlib import Path import pytest @@ -21,6 +24,23 @@ CLIENT = str(BASE_DIR / "traffic_masking_client.py") +@dataclass +class SpawnedProcess: + """A live-test child process and its current log cursor.""" + + process: subprocess.Popen + log_path: Path + log_offset: int = 0 + + def mark_log(self): + """Move the cursor to the current end of the process log.""" + try: + self.log_offset = self.log_path.stat().st_size + except FileNotFoundError: + self.log_offset = 0 + return self.log_offset + + def pytest_configure(config): config.addinivalue_line( "markers", @@ -38,59 +58,130 @@ def free_udp_port(): sock.close() -def read_log(log_path): - """Return the current contents of a spawned process log (empty if absent).""" +def _path_from_log(log): + return log.log_path if isinstance(log, SpawnedProcess) else Path(log) + + +def read_log(log, offset=0): + """Return process log text from ``offset`` (empty if the log is absent).""" try: - return Path(log_path).read_text(errors="replace") + return _path_from_log(log).read_bytes()[offset:].decode(errors="replace") except FileNotFoundError: return "" -def wait_for(log_path, needle, timeout): - """Poll a process log until it contains `needle` or the timeout elapses.""" +def _log_tail(log, offset=0, limit=4000): + contents = read_log(log, offset=offset) + return contents[-limit:] if contents else "" + + +def wait_for(log, needle, timeout, offset=0, report_failure=True): + """Poll a process log for ``needle``, reporting a useful tail on failure.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: - if needle in read_log(log_path): + if needle in read_log(log, offset=offset): return True + if isinstance(log, SpawnedProcess) and log.process.poll() is not None: + break time.sleep(0.1) + if report_failure: + print( + f"Timed out waiting for {needle!r} in {_path_from_log(log)}:\n" + f"{_log_tail(log, offset=offset)}", + file=sys.stderr, + ) return False -def last_match(log_path, pattern): +def last_match(log, pattern, offset=0): """Return the last regex group-1 match in a log as float, or None.""" import re - values = re.findall(pattern, read_log(log_path)) + values = re.findall(pattern, read_log(log, offset=offset)) return float(values[-1]) if values else None +def stop_process(spawned, timeout=3): + """Terminate a spawned process group, escalating to SIGKILL after timeout.""" + process = spawned.process if isinstance(spawned, SpawnedProcess) else spawned + if process.poll() is not None: + return + + try: + os.killpg(process.pid, signal.SIGTERM) + except ProcessLookupError: + return + + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=timeout) + + @pytest.fixture def spawn(tmp_path): """Launch traffic-masking scripts as subprocesses; guarantee teardown. - Returns spawn(script, args, name) -> (Popen, log_path). + Returns a ``SpawnedProcess`` with process, log path, and current log offset. """ procs = [] def _spawn(script, args, name): log_path = tmp_path / f"{name}.log" - handle = open(log_path, "w") + handle = log_path.open("w") + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" proc = subprocess.Popen( [sys.executable, script, *args], stdout=handle, stderr=subprocess.STDOUT, + env=env, + start_new_session=True, ) - procs.append((proc, handle)) - return proc, str(log_path) + spawned = SpawnedProcess(proc, log_path) + procs.append((spawned, handle)) + return spawned yield _spawn - for proc, handle in procs: - if proc.poll() is None: - proc.terminate() - try: - proc.wait(timeout=3) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() + for spawned, handle in reversed(procs): + stop_process(spawned) handle.close() + + +@pytest.fixture +def start_server(spawn): + """Start a server on a late-reserved port, retrying bind races only.""" + + def _start(args_for_port, name, attempts=5, port=None): + last_log = None + for attempt in range(attempts): + selected_port = port if port is not None else free_udp_port() + spawned = spawn( + SERVER, args_for_port(selected_port), f"{name}-{attempt}" + ) + if wait_for( + spawned, "started", 5.0, report_failure=False + ): + return spawned, selected_port + + last_log = read_log(spawned) + bind_conflict = ( + "address already in use" in last_log.lower() + or "errno 98" in last_log.lower() + ) + stop_process(spawned) + if not bind_conflict: + pytest.fail(f"Server failed to start:\n{_log_tail(spawned)}") + time.sleep(0.1) + + pytest.fail( + f"Server port remained busy after {attempts} attempts:\n" + f"{last_log or ''}" + ) + + return _start diff --git a/traffic-masking/masking_lib.py b/traffic-masking/masking_lib.py index e39ce2d..d41b267 100644 --- a/traffic-masking/masking_lib.py +++ b/traffic-masking/masking_lib.py @@ -153,7 +153,7 @@ def voip_call(codec: Optional[str] = None) -> List[PatternStep]: @staticmethod def file_transfer_session(target_mbps: float = 10.0) -> List[PatternStep]: - bps = max(0.5, target_mbps) * 1024 * 1024 / 8 # bytes/sec + bps = mbps_to_bytes_per_second(max(0.5, target_mbps)) mtu_pay = random.randint(1100, 1400) interval = mtu_pay / bps steps: List[PatternStep] = [] diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index f547a59..67d1efd 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -24,6 +24,11 @@ {"target_mbps": 5, "mtu": 0}, {"target_mbps": 5, "entropy": 1.5}, {"target_mbps": 5, "stats_interval": 0}, + {"target_mbps": float("nan")}, + {"target_mbps": float("inf")}, + {"min_mbps": float("nan"), "max_mbps": 8}, + {"target_mbps": 5, "stats_interval": float("inf")}, + {"target_mbps": 5, "mtu": float("inf")}, ], ) def test_server_rejects_bad_config(kwargs): @@ -45,6 +50,9 @@ def test_server_accepts_valid_floating_config(): {"entropy": 2.0}, {"mtu": 0}, {"stats_interval": -1}, + {"response_ratio": float("nan")}, + {"stats_interval": float("inf")}, + {"mtu": float("inf")}, ], ) def test_client_rejects_bad_config(kwargs): @@ -52,6 +60,11 @@ def test_client_rejects_bad_config(kwargs): AdaptiveTrafficClient("127.0.0.1", 8888, **kwargs) +def test_client_default_response_is_download_only(): + client = AdaptiveTrafficClient("127.0.0.1", 8888) + assert client.response_ratio == 0.0 + + def _run(script, *args): return subprocess.run( [sys.executable, script, *args], @@ -61,13 +74,26 @@ def _run(script, *args): ) -def test_server_cli_bad_rate_exits_2(): - assert _run(SERVER, "--mbps", "0").returncode == 2 - - -def test_server_cli_partial_range_exits_2(): - assert _run(SERVER, "--min-mbps", "2").returncode == 2 - - -def test_client_cli_bad_response_exits_2(): - assert _run(CLIENT, "--server", "127.0.0.1", "--response", "2").returncode == 2 +@pytest.mark.parametrize( + ("script", "args", "message"), + [ + (SERVER, ("--mbps", "0"), "positive finite number"), + (SERVER, ("--mbps", "nan"), "positive finite number"), + (SERVER, ("--min-mbps", "2"), "must be given together"), + (SERVER, ("--stats-interval", "inf"), "positive finite number"), + ( + CLIENT, + ("--server", "127.0.0.1", "--response", "2"), + "response ratio must be in [0.0, 1.0]", + ), + ( + CLIENT, + ("--server", "127.0.0.1", "--stats-interval", "nan"), + "stats-interval must be a positive finite number", + ), + ], +) +def test_invalid_cli_exits_2_with_useful_message(script, args, message): + result = _run(script, *args) + assert result.returncode == 2 + assert message in result.stderr diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index d081a66..7152597 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -14,7 +14,7 @@ import pytest -from conftest import CLIENT, SERVER, free_udp_port, last_match, read_log, wait_for +from conftest import CLIENT, last_match, read_log, stop_process, wait_for pytestmark = pytest.mark.live @@ -27,13 +27,11 @@ def _server_args(port, lo=2, hi=4): ] -def test_transmission_bidirectional(spawn): +def test_transmission_bidirectional(spawn, start_server): """Client connects, receives downlink and emits uplink; server sees the client.""" - port = free_udp_port() - _server, slog = spawn(SERVER, _server_args(port), "server") - assert wait_for(slog, "started", 5.0), read_log(slog) + server, port = start_server(_server_args, "server") - _client, clog = spawn( + client = spawn( CLIENT, [ "--server", "127.0.0.1", "--port", str(port), @@ -43,100 +41,103 @@ def test_transmission_bidirectional(spawn): "client", ) - assert wait_for(clog, "Rx:", 10.0), read_log(clog) - assert wait_for(slog, "New client connected", 5.0), read_log(slog) + assert wait_for(client, "Rx:", 10.0), read_log(client) + assert wait_for(server, "New client connected", 5.0), read_log(server) # Let a few stats windows accumulate, then check real downlink/uplink. time.sleep(4) - rx = last_match(clog, r"Rx:\s*([0-9.]+)\s*Mbps") - tx = last_match(clog, r"Tx:\s*([0-9.]+)\s*Mbps") - assert rx is not None and rx > 0.0, read_log(clog) - assert tx is not None and tx > 0.0, read_log(clog) + rx = last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") + tx = last_match(client, r"Tx:\s*([0-9.]+)\s*Mbps") + assert rx is not None and rx > 0.0, read_log(client) + assert tx is not None and tx > 0.0, read_log(client) -def test_reconnection_after_server_restart(spawn): +def test_reconnection_after_server_restart(spawn, start_server): """Three-phase: connected -> server down (no false success) -> restarted -> resumed.""" - port = free_udp_port() - args = _server_args(port) + server, port = start_server(_server_args, "server1") - server, slog = spawn(SERVER, args, "server1") - assert wait_for(slog, "started", 5.0), read_log(slog) - - _client, clog = spawn( + client = spawn( CLIENT, ["--server", "127.0.0.1", "--port", str(port), "--response", "0.3", "--stats-interval", "1"], "client", ) - assert wait_for(clog, "Rx:", 10.0), read_log(clog) + assert wait_for(client, "Rx:", 10.0), read_log(client) # Phase 2: kill the server; the client must detect loss and must NOT falsely # report a reconnect while the server is down. - server.terminate() - server.wait(timeout=5) - assert wait_for(clog, "Connection lost", 20.0), read_log(clog) - downtime = read_log(clog).split("Connection lost", 1)[1] - assert "Reconnected successfully" not in downtime, read_log(clog) + stop_process(server) + assert wait_for(client, "Connection lost", 20.0), read_log(client) + downtime = read_log(client).split("Connection lost", 1)[1] + assert "Reconnected successfully" not in downtime, read_log(client) # Phase 3: restart the server; the client must reconnect. - _server2, slog2 = spawn(SERVER, args, "server2") - assert wait_for(slog2, "started", 5.0), read_log(slog2) - assert wait_for(clog, "Reconnected successfully", 25.0), read_log(clog) + reconnect_offset = client.mark_log() + server2, _ = start_server(_server_args, "server2", port=port) + assert wait_for( + client, "Reconnected successfully", 25.0, offset=reconnect_offset + ), read_log(client, offset=reconnect_offset) + assert server2.process.poll() is None -def test_fixed_rate_is_not_inflated(spawn): +def test_fixed_rate_is_not_inflated(spawn, start_server): """Characterization: --mbps 1 emits on the order of 1 Mbit/s, not ~8.8. The legacy pattern generator legitimately scales the commanded rate (bursts up to 4x for single windows), so this only pins the gross unit error: the old bits-as-bytes budget inflated the average ~8.8x. """ - port = free_udp_port() - _server, slog = spawn( - SERVER, - ["--host", "127.0.0.1", "--port", str(port), "--mbps", "1", - "--stats-interval", "1"], + server, port = start_server( + lambda selected_port: [ + "--host", "127.0.0.1", "--port", str(selected_port), + "--mbps", "1", "--stats-interval", "1", + ], "server", ) - assert wait_for(slog, "started", 5.0), read_log(slog) - _client, clog = spawn( + client = spawn( CLIENT, ["--server", "127.0.0.1", "--port", str(port), "--stats-interval", "1"], "client", ) - assert wait_for(clog, "Rx:", 10.0), read_log(clog) + assert wait_for(client, "Rx:", 10.0), read_log(client) time.sleep(6) - rates = [float(m) for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(slog))] - assert rates, read_log(slog) + rates = [ + float(m) + for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(server)) + ] + assert rates, read_log(server) mean_rate = sum(rates) / len(rates) assert 0.1 <= mean_rate <= 3.0, rates assert max(rates) <= 5.0, rates -def test_floating_rate_stays_within_bounds(spawn): +def test_floating_rate_stays_within_bounds(spawn, start_server): """Characterization: the emitted server rate stays within a slack of [min,max]. (The old realistic-pattern runner's boundary-coverage "quality" scoring is intentionally not ported: it rewards exact-boundary teleporting, a behaviour a later stage removes.) """ - port = free_udp_port() lo, hi = 2.0, 6.0 - _server, slog = spawn(SERVER, _server_args(port, lo, hi), "server") - assert wait_for(slog, "started", 5.0), read_log(slog) + server, port = start_server( + lambda selected_port: _server_args(selected_port, lo, hi), "server" + ) - _client, clog = spawn( + client = spawn( CLIENT, ["--server", "127.0.0.1", "--port", str(port), "--stats-interval", "1"], "client", ) - assert wait_for(clog, "Rx:", 10.0), read_log(clog) + assert wait_for(client, "Rx:", 10.0), read_log(client) time.sleep(6) - rates = [float(m) for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(slog))] - assert rates, read_log(slog) + rates = [ + float(m) + for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(server)) + ] + assert rates, read_log(server) assert min(rates) >= 0.0 # Generous slack: this only guards against runaway rate, not shape quality. assert max(rates) <= hi * 1.75, rates diff --git a/traffic-masking/test_payload.py b/traffic-masking/test_payload.py index 99f49cb..af2356c 100644 --- a/traffic-masking/test_payload.py +++ b/traffic-masking/test_payload.py @@ -20,12 +20,16 @@ def test_server_payload_is_not_deterministic(): def test_generate_packet_does_not_mutate_global_rng(): gen = PacketGenerator() - random.seed(1234) - before = random.random() - random.seed(1234) - gen.generate_packet(600) - after = random.random() - assert before == after + original_state = random.getstate() + try: + random.seed(1234) + before = random.random() + random.seed(1234) + gen.generate_packet() + after = random.random() + assert before == after + finally: + random.setstate(original_state) def test_generate_packet_guards_small_sizes(): @@ -41,3 +45,17 @@ def test_client_response_payload_is_not_deterministic(): p2 = client.generate_response_packet(600) # Header is [type 1][seq 4][ts 8] = 13 bytes; the payload after must differ. assert p1[13:] != p2[13:] + + +def test_client_response_does_not_mutate_global_rng(): + client = AdaptiveTrafficClient("127.0.0.1", 9) + original_state = random.getstate() + try: + random.seed(5678) + before = random.random() + random.seed(5678) + client.generate_response_packet() + after = random.random() + assert before == after + finally: + random.setstate(original_state) diff --git a/traffic-masking/test_rate.py b/traffic-masking/test_rate.py index a81917a..b32c4df 100644 --- a/traffic-masking/test_rate.py +++ b/traffic-masking/test_rate.py @@ -3,8 +3,21 @@ """Rate correctness: decimal-Mbps conversion and pacing budget accounting.""" -from masking_lib import mbps_to_bytes_per_second -from traffic_masking_server import MaskingTrafficServer, _budget_bytes +import masking_lib +import pytest +from masking_lib import ProtocolMimicry, mbps_to_bytes_per_second +from traffic_masking_server import MaskingTrafficServer, _budget_bytes, _RateBudget + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds def test_mbps_to_bytes_per_second_is_decimal(): @@ -28,6 +41,19 @@ def test_fixed_and_floating_conversions_are_identical(): assert fixed.target_bytes_per_second == floating.target_bytes_per_second +def test_file_transfer_profile_uses_decimal_mbps(monkeypatch): + monkeypatch.setattr(masking_lib.random, "randint", lambda low, high: low) + + def deterministic_uniform(low, high): + if (low, high) in ((0.92, 1.0), (0.9, 1.1)): + return 1.0 + return (low + high) / 2 + + monkeypatch.setattr(masking_lib.random, "uniform", deterministic_uniform) + first_step = ProtocolMimicry.file_transfer_session(target_mbps=1.0)[0] + assert first_step.size / first_step.delay == pytest.approx(125_000) + + def test_budget_zero_for_nonpositive_inputs(): assert _budget_bytes(0, 1.0) == 0 assert _budget_bytes(-5, 1.0) == 0 @@ -39,15 +65,39 @@ def test_budget_caps_idle_gap_to_one_tick(): # A long idle gap must not turn into accumulated credit: at most one # scheduling tick of bytes is granted no matter how much time passed. rate = 125_000 # 1 Mbps - assert _budget_bytes(rate, 60.0) == _budget_bytes(rate, 0.5) - assert _budget_bytes(rate, 3600.0) == _budget_bytes(rate, 0.5) + assert _budget_bytes(rate, 60.0) == _budget_bytes(rate, 0.1) + assert _budget_bytes(rate, 3600.0) == _budget_bytes(rate, 0.1) def test_budget_tracks_configured_rate_over_fake_clock_window(): # Simulate the pacing loop over a fake one-second window of 10 ms ticks # at a constant commanded rate; the granted budget must stay within ±15% # of the configured decimal rate (exact modulo integer truncation). - rate = mbps_to_bytes_per_second(1) # 125_000 bytes/s - ticks = 100 - granted = sum(_budget_bytes(rate, 0.01) for _ in range(ticks)) - assert abs(granted - 125_000) <= 125_000 * 0.15 + clock = FakeClock() + budget = _RateBudget(clock=clock) + target = mbps_to_bytes_per_second(1) + submitted = 0 + + for _ in range(100): + clock.advance(0.01) + allowed = budget.accrue(target) + submitted += allowed + budget.consume(allowed) + + assert abs(submitted - target) <= target * 0.15 + + +def test_rate_budget_caps_elapsed_and_resets_idle_credit(): + clock = FakeClock() + budget = _RateBudget(clock=clock) + target = mbps_to_bytes_per_second(1) + + clock.advance(60) + assert budget.accrue(target) == _budget_bytes(target, 0.1) + budget.reset() + assert budget.available == 0 + + clock.advance(0.01) + assert budget.accrue(target) == pytest.approx( + _budget_bytes(target, 0.01), abs=1 + ) diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index f1af620..68fe89b 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -10,6 +10,7 @@ """ import argparse +import math import os import random import socket @@ -40,7 +41,7 @@ def __init__( self, server_host, server_port, - response_ratio=0.3, + response_ratio=0.0, advanced=False, uplink_profile="mixed", header="none", @@ -48,16 +49,30 @@ def __init__( mtu=1200, entropy=1.0, stats_interval=5.0, + rng=None, + byte_source=None, ): # Validate configuration up front; fail fast on invalid inputs. - if not (0.0 <= float(response_ratio) <= 1.0): + try: + response_ratio = float(response_ratio) + entropy = float(entropy) + stats_interval = float(stats_interval) + except (TypeError, ValueError): + raise ValueError( + "response, entropy, and stats-interval must be numbers" + ) from None + if not math.isfinite(response_ratio) or not 0.0 <= response_ratio <= 1.0: raise ValueError("response ratio must be in [0.0, 1.0]") - if not (0.0 <= float(entropy) <= 1.0): + if not math.isfinite(entropy) or not 0.0 <= entropy <= 1.0: raise ValueError("entropy must be in [0.0, 1.0]") - if int(mtu) <= 0: + try: + mtu = int(mtu) + except (TypeError, ValueError, OverflowError): + raise ValueError("mtu must be a positive integer") from None + if mtu <= 0: raise ValueError("mtu must be positive") - if float(stats_interval) <= 0: - raise ValueError("stats-interval must be positive") + if not math.isfinite(stats_interval) or stats_interval <= 0: + raise ValueError("stats-interval must be a positive finite number") self.server_host = server_host self.server_port = server_port @@ -77,14 +92,16 @@ def __init__( self.received_rate = 0 self.rate_window = [] self.sequence = 0 - self.stats_interval = float(stats_interval) + self._rng = rng or random.Random() + self._byte_source = byte_source or os.urandom + self.stats_interval = stats_interval # Advanced obfuscation settings self.advanced = bool(advanced) self.obf_cfg = ObfuscationConfig( padding_strategy=padding, header_mode=header, - mtu=int(mtu), - entropy=float(entropy), + mtu=mtu, + entropy=entropy, timing_jitter=0.002, ) self.uplink_profile = parse_profile(uplink_profile) @@ -179,11 +196,11 @@ def generate_response_packet(self, size=None): """Generate uplink response packet""" if size is None: # Vary response size - size = random.choice( + size = self._rng.choice( [ - random.randint(64, 200), # Small ACK-like - random.randint(200, 600), # Medium - random.randint(600, 1200), # Large + self._rng.randint(64, 200), # Small ACK-like + self._rng.randint(200, 600), # Medium + self._rng.randint(600, 1200), # Large ] ) @@ -198,7 +215,7 @@ def generate_response_packet(self, size=None): data_size = max(0, size - header_size) # Bulk CSPRNG payload (no per-byte Python RNG in the hot path). - random_data = os.urandom(data_size) + random_data = self._byte_source(data_size) return packet_type + seq_bytes + timestamp + random_data diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index be03927..9d98146 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -11,8 +11,10 @@ import argparse import hashlib +import math import os import random +import signal import socket import struct import threading @@ -27,7 +29,12 @@ ) -def _budget_bytes(rate_bytes_per_second, elapsed, max_tick=0.5): +_MAX_PACING_TICK_SECONDS = 0.1 + + +def _budget_bytes( + rate_bytes_per_second, elapsed, max_tick=_MAX_PACING_TICK_SECONDS +): """Bytes allowed to send over ``elapsed`` seconds at the given byte rate. ``elapsed`` is clamped to ``max_tick`` so an idle gap (no clients) cannot @@ -38,6 +45,58 @@ def _budget_bytes(rate_bytes_per_second, elapsed, max_tick=0.5): return int(rate_bytes_per_second * min(elapsed, max_tick)) +class _RateBudget: + """Monotonic byte-credit accumulator for the legacy pacing loop.""" + + def __init__(self, clock=None, max_tick=_MAX_PACING_TICK_SECONDS): + if max_tick <= 0: + raise ValueError("max_tick must be positive") + self._clock = clock or time.monotonic + self._max_tick = max_tick + self._last_time = self._clock() + self._credit = 0.0 + + @property + def available(self): + return max(0, int(self._credit)) + + def reset(self): + self._last_time = self._clock() + self._credit = 0.0 + + def accrue(self, rate_bytes_per_second): + now = self._clock() + elapsed = max(0.0, now - self._last_time) + self._last_time = now + if rate_bytes_per_second > 0: + self._credit += rate_bytes_per_second * min(elapsed, self._max_tick) + return self.available + + def consume(self, byte_count): + if byte_count > 0: + self._credit -= byte_count + + +def _positive_finite_float(value, name): + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError(f"{name} must be a number") from None + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be a positive finite number") + return value + + +def _unit_interval_float(value, name): + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError(f"{name} must be a number") from None + if not math.isfinite(value) or not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be in [0.0, 1.0]") + return value + + class TrafficPattern: """Generator of diverse traffic rate patterns (CBR, bursts, waves, random-walk, media-like)""" @@ -107,10 +166,12 @@ def get_current_rate(self, base_rate): class PacketGenerator: """Packet generator with variable sizes and pseudo-random payload characteristics""" - def __init__(self, min_size=64, max_size=1400): + def __init__(self, min_size=64, max_size=1400, rng=None, byte_source=None): self.min_size = min_size self.max_size = max_size self.sequence = 0 + self._rng = rng or random.Random() + self._byte_source = byte_source or os.urandom def generate_packet(self, target_size=None): """Generate a data packet""" @@ -118,13 +179,13 @@ def generate_packet(self, target_size=None): # Packet size distribution (simulate realistic traffic) weights = [0.1, 0.15, 0.5, 0.15, 0.1] # Favor medium packets sizes = [ - random.randint(self.min_size, 200), # Small - random.randint(200, 500), # Small-medium - random.randint(500, 1000), # Medium - random.randint(1000, 1300), # Medium-large - random.randint(1300, self.max_size), # Large + self._rng.randint(self.min_size, 200), # Small + self._rng.randint(200, 500), # Small-medium + self._rng.randint(500, 1000), # Medium + self._rng.randint(1000, 1300), # Medium-large + self._rng.randint(1300, self.max_size), # Large ] - size = random.choices(sizes, weights=weights)[0] + size = self._rng.choices(sizes, weights=weights)[0] else: size = min(max(target_size, self.min_size), self.max_size) @@ -137,7 +198,7 @@ def generate_packet(self, target_size=None): # in the hot path: it made same-size payloads identical within a window # and corrupted the shared random stream used by other threads. data_size = max(0, size - 28) # 4 + 8 + 16 = 28 bytes header - random_data = os.urandom(data_size) + random_data = self._byte_source(data_size) # Calculate checksum packet_content = seq_bytes + timestamp + random_data @@ -169,18 +230,25 @@ def __init__( if (min_mbps is None) != (max_mbps is None): raise ValueError("min-mbps and max-mbps must be given together") if floating: - if min_mbps <= 0 or max_mbps <= 0: - raise ValueError("min-mbps and max-mbps must be positive") + min_mbps = _positive_finite_float(min_mbps, "min-mbps") + max_mbps = _positive_finite_float(max_mbps, "max-mbps") if min_mbps >= max_mbps: raise ValueError("min-mbps must be less than max-mbps") - elif target_mbps is None or target_mbps <= 0: - raise ValueError("target rate (--mbps) must be positive") - if int(mtu) <= 0: + target_mbps = None + else: + target_mbps = _positive_finite_float( + target_mbps, "target rate (--mbps)" + ) + try: + mtu = int(mtu) + except (TypeError, ValueError, OverflowError): + raise ValueError("mtu must be a positive integer") from None + if mtu <= 0: raise ValueError("mtu must be positive") - if not (0.0 <= float(entropy) <= 1.0): - raise ValueError("entropy must be in [0.0, 1.0]") - if float(stats_interval) <= 0: - raise ValueError("stats-interval must be positive") + entropy = _unit_interval_float(entropy, "entropy") + stats_interval = _positive_finite_float( + stats_interval, "stats-interval" + ) self.host = host self.port = port @@ -199,7 +267,7 @@ def __init__( self.packet_gen = PacketGenerator() self.stats = {"bytes_sent": 0, "packets_sent": 0, "start_time": time.time()} self.last_stats = {"bytes_sent": 0, "packets_sent": 0, "time": time.time()} - self.stats_interval = float(stats_interval) + self.stats_interval = stats_interval # Advanced masking options self.advanced = bool(advanced) # Normalize profile to TrafficProfile @@ -213,8 +281,8 @@ def __init__( self.profile = TrafficProfile.MIXED self.header_mode = header self.padding_strategy = padding - self.mtu = int(mtu) - self.entropy = float(entropy) + self.mtu = mtu + self.entropy = entropy self.obfuscator = None self.generator = None @@ -296,8 +364,7 @@ def send_loop(self): """Send cover traffic to clients""" # Pacing uses the monotonic clock: wall-clock steps (NTP) must not # produce negative or inflated byte budgets. - last_send_time = time.monotonic() - bytes_accumulator = 0 + rate_budget = _RateBudget() # Rate control for advanced mode rate_window_bytes = 0 @@ -308,8 +375,7 @@ def send_loop(self): time.sleep(0.1) # No clients: reset pacing so idle time is not billed as a burst # to the next client that connects. - last_send_time = time.monotonic() - bytes_accumulator = 0 + rate_budget.reset() continue # Advanced generator-driven mode with proper rate limiting @@ -359,24 +425,21 @@ def send_loop(self): continue # Legacy accumulator mode (default) - current_time = time.monotonic() - elapsed = current_time - last_send_time - # Current target rate in bytes/s (the pattern scales the byte budget). current_rate_bps = self.pattern_gen.get_current_rate( self.target_bytes_per_second ) - # Bytes allowed for this interval; an idle gap is capped to one tick. - bytes_accumulator += _budget_bytes(current_rate_bps, elapsed) + # Bytes allowed for this interval; elapsed time is capped to one tick. + bytes_available = rate_budget.accrue(current_rate_bps) # Send packets in batches for efficiency packets_sent_this_round = 0 while ( - bytes_accumulator > 0 and self.clients and packets_sent_this_round < 50 + bytes_available > 0 and self.clients and packets_sent_this_round < 50 ): # Generate larger packets for better throughput - packet_size = min(bytes_accumulator, random.randint(1000, 1400)) + packet_size = min(bytes_available, random.randint(1000, 1400)) packet = self.packet_gen.generate_packet(packet_size) # Send to all active clients @@ -388,17 +451,16 @@ def send_loop(self): except Exception as e: print(f"[!] Send error to client {addr}: {e}") - bytes_accumulator -= len(packet) + rate_budget.consume(len(packet)) + bytes_available = rate_budget.available packets_sent_this_round += 1 # Minimal sleep between packets in batch if packets_sent_this_round % 10 == 0: time.sleep(0.0001) - last_send_time = current_time - # Adaptive pacing based on accumulator - if bytes_accumulator > current_rate_bps * 0.1: + if rate_budget.available > current_rate_bps * 0.1: # Behind schedule, don't sleep pass else: @@ -543,11 +605,18 @@ def main(): except ValueError as exc: parser.error(str(exc)) + shutdown_requested = threading.Event() + + def request_shutdown(_signum, _frame): + shutdown_requested.set() + + signal.signal(signal.SIGINT, request_shutdown) + signal.signal(signal.SIGTERM, request_shutdown) + try: server.start() - while True: - time.sleep(1) - except KeyboardInterrupt: + shutdown_requested.wait() + finally: print("\n[*] Stopping server...", flush=True) server.stop() From a6306f01b739f61cf4ead9ea074b78adb6ef73be Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:05:50 +0300 Subject: [PATCH 04/10] traffic-masking: authenticate control and data sessions --- traffic-masking/.gitignore | 1 + traffic-masking/Dockerfile | 4 +- traffic-masking/EXAMPLES.md | 68 ++- traffic-masking/Makefile | 7 +- traffic-masking/README.md | 49 +- traffic-masking/SUMMARY.md | 11 +- traffic-masking/conftest.py | 10 + traffic-masking/control_protocol.py | 325 +++++++++++ traffic-masking/systemd/README.md | 29 +- .../systemd/traffic-masking-client.service | 6 +- .../systemd/traffic-masking-server.service | 6 +- traffic-masking/test_cli.py | 75 ++- traffic-masking/test_control_protocol.py | 541 ++++++++++++++++++ traffic-masking/test_imports.py | 1 + traffic-masking/test_live.py | 116 +++- traffic-masking/test_payload.py | 7 +- traffic-masking/test_rate.py | 9 +- traffic-masking/traffic_masking_client.py | 310 +++++++++- traffic-masking/traffic_masking_server.py | 448 +++++++++++++-- 19 files changed, 1884 insertions(+), 139 deletions(-) create mode 100644 traffic-masking/control_protocol.py create mode 100644 traffic-masking/test_control_protocol.py diff --git a/traffic-masking/.gitignore b/traffic-masking/.gitignore index 08f0246..bdf6dc7 100644 --- a/traffic-masking/.gitignore +++ b/traffic-masking/.gitignore @@ -5,3 +5,4 @@ __pycache__/ .coverage htmlcov/ *.log +*.psk diff --git a/traffic-masking/Dockerfile b/traffic-masking/Dockerfile index ac383c2..1fb7676 100644 --- a/traffic-masking/Dockerfile +++ b/traffic-masking/Dockerfile @@ -24,7 +24,7 @@ COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt # Copy application files -COPY masking_lib.py traffic_masking_server.py traffic_masking_client.py /app/ +COPY control_protocol.py masking_lib.py traffic_masking_server.py traffic_masking_client.py /app/ COPY enhanced/ /app/enhanced/ # Copy documentation @@ -43,4 +43,4 @@ EXPOSE 8888/udp ENTRYPOINT ["python"] # Default command shows usage -CMD ["-c", "print('Usage:\\n Server: python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced\\n Client: python traffic_masking_client.py --server --advanced')"] +CMD ["-c", "print('Usage (mount a mode 0600 PSK at /run/secrets/traffic-masking.psk):\\n Server: python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --psk-file /run/secrets/traffic-masking.psk\\n Client: python traffic_masking_client.py --server --psk-file /run/secrets/traffic-masking.psk')"] diff --git a/traffic-masking/EXAMPLES.md b/traffic-masking/EXAMPLES.md index 9968640..d9c6d5f 100644 --- a/traffic-masking/EXAMPLES.md +++ b/traffic-masking/EXAMPLES.md @@ -3,16 +3,21 @@ ## Quick Start ```bash +umask 077 +openssl rand 32 > traffic-masking.psk + # Test the system without live process/network tests make test-fast # Basic server and client -python traffic_masking_server.py --mbps 5 -python traffic_masking_client.py --server 127.0.0.1 +python traffic_masking_server.py --mbps 5 --psk-file ./traffic-masking.psk +python traffic_masking_client.py --server 127.0.0.1 --psk-file ./traffic-masking.psk # Floating rate (recommended) -python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced -python traffic_masking_client.py --server 127.0.0.1 --response 0.3 --advanced +python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced \ + --psk-file ./traffic-masking.psk +python traffic_masking_client.py --server 127.0.0.1 --response 0.3 --advanced \ + --psk-file ./traffic-masking.psk ``` Mbps values are decimal Mbit/s of application UDP payload. The client defaults @@ -25,15 +30,18 @@ that exact ratio on the wire. ### Mask Video Calls ```bash # Google Meet / Zoom / Teams -python traffic_masking_server.py --min-mbps 1 --max-mbps 5 --advanced --profile video +python traffic_masking_server.py --min-mbps 1 --max-mbps 5 --advanced \ + --profile video --psk-file ./traffic-masking.psk # WhatsApp / Telegram voice calls -python traffic_masking_server.py --min-mbps 0.5 --max-mbps 1.5 --advanced --profile voip +python traffic_masking_server.py --min-mbps 0.5 --max-mbps 1.5 --advanced \ + --profile voip --psk-file ./traffic-masking.psk ``` ### Mask Web Browsing ```bash -python traffic_masking_server.py --min-mbps 1 --max-mbps 4 --advanced --profile web --header quic +python traffic_masking_server.py --min-mbps 1 --max-mbps 4 --advanced \ + --profile web --header quic --psk-file ./traffic-masking.psk ``` ### Maximum Security Configuration @@ -43,13 +51,14 @@ python traffic_masking_server.py \ --min-mbps 3 --max-mbps 10 \ --advanced --profile mixed \ --header rtp --padding random \ - --entropy 1.0 + --entropy 1.0 --psk-file ./traffic-masking.psk # Client python traffic_masking_client.py \ --server SERVER_IP --response 0.4 \ --advanced --uplink-profile mixed \ - --header rtp --padding random + --header rtp --padding random \ + --psk-file ./traffic-masking.psk ``` ### Performance Optimized @@ -57,7 +66,8 @@ python traffic_masking_client.py \ # Lower CPU usage, good throughput python traffic_masking_server.py \ --mbps 8 --advanced --profile web \ - --header none --padding none --entropy 0.7 + --header none --padding none --entropy 0.7 \ + --psk-file ./traffic-masking.psk ``` ## Integration @@ -76,12 +86,16 @@ PreDown = systemctl stop traffic-masking-server docker build -t traffic-masking . # Server -docker run -d --name tm-server --network host traffic-masking \ - traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced +docker run -d --name tm-server --network host \ + --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ + traffic-masking traffic_masking_server.py --min-mbps 2 --max-mbps 8 \ + --advanced --psk-file /run/secrets/traffic-masking.psk # Client -docker run -d --name tm-client --network host traffic-masking \ - traffic_masking_client.py --server SERVER_IP --response 0.3 --advanced +docker run -d --name tm-client --network host \ + --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ + traffic-masking traffic_masking_client.py --server SERVER_IP --response 0.3 \ + --advanced --psk-file /run/secrets/traffic-masking.psk ``` ### Docker Compose @@ -91,13 +105,17 @@ services: server: build: . network_mode: host - command: traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced + command: traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced --psk-file /run/secrets/traffic-masking.psk + volumes: + - ./traffic-masking.psk:/run/secrets/traffic-masking.psk:ro restart: unless-stopped client: build: . network_mode: host - command: traffic_masking_client.py --server ${SERVER_IP} --response 0.3 --advanced + command: traffic_masking_client.py --server ${SERVER_IP} --response 0.3 --advanced --psk-file /run/secrets/traffic-masking.psk + volumes: + - ./traffic-masking.psk:/run/secrets/traffic-masking.psk:ro restart: unless-stopped depends_on: - server @@ -120,17 +138,20 @@ sudo sysctl -p ### Process Priority ```bash # High priority -sudo nice -n -10 python traffic_masking_server.py --min-mbps 5 --max-mbps 15 --advanced +sudo nice -n -10 python traffic_masking_server.py --min-mbps 5 --max-mbps 15 \ + --advanced --psk-file ./traffic-masking.psk # CPU affinity (cores 0,1) -taskset -c 0,1 python traffic_masking_server.py --min-mbps 5 --max-mbps 15 --advanced +taskset -c 0,1 python traffic_masking_server.py --min-mbps 5 --max-mbps 15 \ + --advanced --psk-file ./traffic-masking.psk ``` ### PyPy for Better Performance ```bash sudo apt-get install pypy3 pypy3 -m pip install numpy -pypy3 traffic_masking_server.py --min-mbps 5 --max-mbps 15 --advanced +pypy3 traffic_masking_server.py --min-mbps 5 --max-mbps 15 --advanced \ + --psk-file ./traffic-masking.psk ``` ## Monitoring @@ -154,13 +175,14 @@ grep "Rate:" server.log | awk '{sum+=$4; count++} END {print sum/count}' ## Troubleshooting ```bash -# Test connectivity -nc -u -v SERVER_IP 8888 -echo "TEST" | nc -u -w1 SERVER_IP 8888 +# Test authenticated connectivity +python traffic_masking_client.py --server SERVER_IP \ + --psk-file ./traffic-masking.psk --stats-interval 1 # Debug mode with verbose output PYTHONUNBUFFERED=1 python -u traffic_masking_server.py \ - --mbps 5 --advanced --stats-interval 1 2>&1 | tee server.log + --mbps 5 --advanced --stats-interval 1 \ + --psk-file ./traffic-masking.psk 2>&1 | tee server.log # Network statistics netstat -su | grep -A 5 Udp: diff --git a/traffic-masking/Makefile b/traffic-masking/Makefile index 5123e5a..18b1b24 100644 --- a/traffic-masking/Makefile +++ b/traffic-masking/Makefile @@ -7,7 +7,8 @@ PIP := $(VENV)/bin/pip RUFF := $(VENV)/bin/ruff DEPS_STAMP := $(VENV)/.deps-installed -COV := --cov=masking_lib --cov=traffic_masking_server --cov=traffic_masking_client \ +COV := --cov=control_protocol --cov=masking_lib \ + --cov=traffic_masking_server --cov=traffic_masking_client \ --cov=enhanced --cov-branch --cov-report=term-missing .PHONY: venv test test-fast test-live lint run-server run-client clean @@ -35,10 +36,10 @@ lint: venv ## static checks $(RUFF) check . run-server: venv ## demo server (floating 2-8 Mbps, advanced) - $(PYTHON) traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced --profile mixed + $(PYTHON) traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced --profile mixed --insecure-diagnostic run-client: venv ## demo client against 127.0.0.1 - $(PYTHON) traffic_masking_client.py --server 127.0.0.1 --advanced --uplink-profile mixed + $(PYTHON) traffic_masking_client.py --server 127.0.0.1 --advanced --uplink-profile mixed --insecure-diagnostic clean: ## remove venv and generated artifacts rm -rf $(VENV) __pycache__ enhanced/__pycache__ \ diff --git a/traffic-masking/README.md b/traffic-masking/README.md index a9e7045..f4dea14 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -11,6 +11,8 @@ UDP-based cover traffic generator designed to mask traffic patterns inside encry - **Protocol mimicry**: 6 traffic profiles (web, video, voip, file, gaming, mixed) - **Bidirectional flow**: Adaptive client uplink response - **Auto-reconnection**: Client recovers automatically after server restart or network loss +- **Authenticated enrollment**: Source-bound challenge cookies and HMAC-SHA256 + session framing prevent unauthenticated cover-traffic amplification ## Installation @@ -22,17 +24,27 @@ pip install -r requirements.txt ## Quick Start +Create one binary PSK and install the same file on both endpoints. Keep the file +out of source control and do not pass the key value on the command line. + +```bash +umask 077 +openssl rand 32 > traffic-masking.psk +``` + ### Basic Usage ```bash # Server with fixed rate -python traffic_masking_server.py --mbps 5 +python traffic_masking_server.py --mbps 5 --psk-file ./traffic-masking.psk # Server with floating rate (recommended) -python traffic_masking_server.py --min-mbps 2 --max-mbps 8 +python traffic_masking_server.py --min-mbps 2 --max-mbps 8 \ + --psk-file ./traffic-masking.psk # Client -python traffic_masking_client.py --server +python traffic_masking_client.py --server \ + --psk-file ./traffic-masking.psk ``` ### Advanced Mode @@ -42,13 +54,15 @@ python traffic_masking_client.py --server python traffic_masking_server.py \ --min-mbps 3 --max-mbps 10 \ --advanced --profile mixed \ - --header rtp --padding random + --header rtp --padding random \ + --psk-file ./traffic-masking.psk # Client with matching configuration # A nonzero response is an explicit diagnostic/profile uplink choice. python traffic_masking_client.py \ --server --response 0.3 \ - --advanced --uplink-profile mixed + --advanced --uplink-profile mixed \ + --psk-file ./traffic-masking.psk ``` ## Testing @@ -76,6 +90,23 @@ make test - `--header`: Pseudo-headers (none/rtp/quic) - `--padding`: Padding strategy (none/random/fixed_buckets/progressive) - `--entropy`: Payload entropy (0.0-1.0) +- `--psk-file`: Path to the shared 32-4096 byte binary key. The file must not + grant group or other permissions. +- `--max-clients`, `--max-total-mbps`: Bound authenticated enrollment and the + configured aggregate egress commitment. +- `--max-handshakes-per-second`: Bound global handshake processing. Pending and + replay state expires with the cookie window; full state refuses new enrollment + rather than evicting an authenticated client. + +`--insecure-diagnostic` uses a public built-in key and is only for local +diagnostics. Production startup fails closed when the PSK is missing, +unreadable, too short, too large, or has permissive file modes. + +## Key Rotation + +There is no multi-key grace period. Generate a replacement file with mode +`0600`, stop both endpoints, atomically replace the old file on both hosts, and +restart both processes. Never log the key or put its value in a service command. ## Documentation @@ -87,9 +118,15 @@ make test ```bash docker build -t traffic-masking . -docker run --network host traffic-masking traffic_masking_server.py --min-mbps 2 --max-mbps 8 +docker run --network host \ + --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ + traffic-masking traffic_masking_server.py --min-mbps 2 --max-mbps 8 \ + --psk-file /run/secrets/traffic-masking.psk ``` +The mounted secret must be readable by container UID 1000 while retaining mode +`0400` or `0600` and no group/other permission bits. + ## Systemd See [systemd/](systemd/) directory for service unit files. diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index f641916..94abcbb 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -10,12 +10,21 @@ - `ProtocolMimicry`: Pattern generation for different traffic profiles - `TrafficProfile`: Enum for supported profiles (web, video, voip, file, gaming, mixed) +**control_protocol.py** +- Versioned binary envelope for control and data datagrams +- HMAC-SHA256 authentication with direction-specific session keys and monotonic + sequences +- Stateless, source-bound challenge cookies with bounded pre-validation replies +- Restrictive PSK file validation + **traffic_masking_server.py** - Multi-client UDP server with batch processing +- Authenticated client enrollment with client, handshake-rate, and total-rate caps - Adaptive rate control with floating mode - Real-time statistics monitoring **traffic_masking_client.py** +- Authenticated challenge/response handshake and source validation - Adaptive uplink generation based on downlink rate - Response ratio control (0-100% of received traffic) @@ -125,4 +134,4 @@ if current_mbps < min_mbps: - Multi-threading support - Rust/C++ performance modules - Distributed operation mode -- Cross-layer coordination with VPN \ No newline at end of file +- Cross-layer coordination with VPN diff --git a/traffic-masking/conftest.py b/traffic-masking/conftest.py index d4f884f..e9b47cf 100644 --- a/traffic-masking/conftest.py +++ b/traffic-masking/conftest.py @@ -22,6 +22,7 @@ BASE_DIR = Path(__file__).resolve().parent SERVER = str(BASE_DIR / "traffic_masking_server.py") CLIENT = str(BASE_DIR / "traffic_masking_client.py") +TEST_PSK = b"traffic-masking-test-key-material-32" @dataclass @@ -48,6 +49,15 @@ def pytest_configure(config): ) +@pytest.fixture +def psk_file(tmp_path): + """Create a restrictive binary PSK file shared by live client/server.""" + path = tmp_path / "control.psk" + path.write_bytes(TEST_PSK) + path.chmod(0o600) + return path + + def free_udp_port(): """Return a currently-free UDP port on loopback.""" sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) diff --git a/traffic-masking/control_protocol.py b/traffic-masking/control_protocol.py new file mode 100644 index 0000000..e390579 --- /dev/null +++ b/traffic-masking/control_protocol.py @@ -0,0 +1,325 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Authenticated control framing and return-routability cookies.""" + +import hashlib +import hmac +import os +import stat +import struct +from dataclasses import dataclass, field +from enum import IntEnum +from pathlib import Path + +MAGIC = b"TMCP" +VERSION = 1 +NONCE_SIZE = 16 +TAG_SIZE = hashlib.sha256().digest_size +MAX_PADDING_SIZE = 64 +CONTROL_PADDING_MAX = 16 +MAX_DATAGRAM_SIZE = 65_507 +MIN_PSK_SIZE = 32 +MAX_PSK_SIZE = 4096 +ZERO_NONCE = bytes(NONCE_SIZE) +INSECURE_DIAGNOSTIC_KEY = hashlib.sha256( + b"traffic-masking/insecure-diagnostic/v1" +).digest() +CLIENT_TO_SERVER = b"client-to-server" +SERVER_TO_CLIENT = b"server-to-client" +_SESSION_DIRECTIONS = frozenset((CLIENT_TO_SERVER, SERVER_TO_CLIENT)) + +_HEADER = struct.Struct("!4sBB16s16sQHH") +_COOKIE_BODY = struct.Struct("!QQ") +HEADER_SIZE = _HEADER.size +FRAME_OVERHEAD = HEADER_SIZE + TAG_SIZE +MAX_PAYLOAD_SIZE = MAX_DATAGRAM_SIZE - FRAME_OVERHEAD +COOKIE_SIZE = _COOKIE_BODY.size + TAG_SIZE +MIN_CONTROL_MTU = FRAME_OVERHEAD + COOKIE_SIZE + CONTROL_PADDING_MAX + + +class ProtocolError(ValueError): + """A datagram or protocol value is invalid.""" + + +class MessageType(IntEnum): + HELLO = 1 + CHALLENGE = 2 + AUTH = 3 + ACCEPT = 4 + KEEPALIVE = 5 + DATA = 6 + + +@dataclass(frozen=True) +class Frame: + message_type: MessageType + client_nonce: bytes + session_nonce: bytes + sequence: int + payload: bytes + padding: bytes + tag: bytes + signed_data: bytes = field(repr=False) + + +@dataclass(frozen=True) +class Cookie: + expires_at: int + hello_sequence: int + + +def _validate_nonce(value, name): + if not isinstance(value, (bytes, bytearray, memoryview)): + raise ProtocolError(f"{name} must be bytes") + value = bytes(value) + if len(value) != NONCE_SIZE: + raise ProtocolError(f"{name} must be exactly {NONCE_SIZE} bytes") + return value + + +def _validate_key(key): + if not isinstance(key, (bytes, bytearray, memoryview)): + raise ProtocolError("authentication key must be bytes") + key = bytes(key) + if not key: + raise ProtocolError("authentication key must not be empty") + return key + + +def encode_frame( + message_type, + client_nonce, + session_nonce, + sequence, + key, + payload=b"", + padding=b"", +): + """Encode and authenticate one control/data frame.""" + try: + message_type = MessageType(message_type) + except ValueError: + raise ProtocolError(f"unknown message type: {message_type}") from None + client_nonce = _validate_nonce(client_nonce, "client nonce") + session_nonce = _validate_nonce(session_nonce, "session nonce") + key = _validate_key(key) + if not isinstance(sequence, int) or not 0 <= sequence < 2**64: + raise ProtocolError("sequence must be an unsigned 64-bit integer") + payload = bytes(payload) + padding = bytes(padding) + if len(payload) > MAX_PAYLOAD_SIZE: + raise ProtocolError("payload is too large") + if len(padding) > MAX_PADDING_SIZE: + raise ProtocolError("padding is too large") + + header = _HEADER.pack( + MAGIC, + VERSION, + int(message_type), + client_nonce, + session_nonce, + sequence, + len(payload), + len(padding), + ) + signed_data = header + payload + padding + if len(signed_data) + TAG_SIZE > MAX_DATAGRAM_SIZE: + raise ProtocolError("encoded datagram is too large") + return signed_data + hmac.new(key, signed_data, hashlib.sha256).digest() + + +def inspect_frame(datagram): + """Parse a frame structurally without treating it as authenticated.""" + datagram = bytes(datagram) + if len(datagram) < FRAME_OVERHEAD: + raise ProtocolError("truncated frame") + + ( + magic, + version, + raw_type, + client_nonce, + session_nonce, + sequence, + payload_length, + padding_length, + ) = _HEADER.unpack_from(datagram) + if magic != MAGIC: + raise ProtocolError("invalid frame magic") + if version != VERSION: + raise ProtocolError(f"unsupported protocol version: {version}") + try: + message_type = MessageType(raw_type) + except ValueError: + raise ProtocolError(f"unknown message type: {raw_type}") from None + if payload_length > MAX_PAYLOAD_SIZE: + raise ProtocolError("declared payload is too large") + if padding_length > MAX_PADDING_SIZE: + raise ProtocolError("declared padding is too large") + + expected_length = HEADER_SIZE + payload_length + padding_length + TAG_SIZE + if len(datagram) != expected_length: + raise ProtocolError("frame length does not match declared lengths") + payload_start = HEADER_SIZE + padding_start = payload_start + payload_length + tag_start = padding_start + padding_length + return Frame( + message_type=message_type, + client_nonce=client_nonce, + session_nonce=session_nonce, + sequence=sequence, + payload=datagram[payload_start:padding_start], + padding=datagram[padding_start:tag_start], + tag=datagram[tag_start:], + signed_data=datagram[:tag_start], + ) + + +def decode_frame(datagram, key): + """Parse a frame and verify its authentication tag.""" + frame = inspect_frame(datagram) + expected_tag = hmac.new( + _validate_key(key), frame.signed_data, hashlib.sha256 + ).digest() + if not hmac.compare_digest(frame.tag, expected_tag): + raise ProtocolError("invalid authentication tag") + return frame + + +def derive_session_key(base_key, client_nonce, session_nonce, direction): + """Derive a direction-specific key bound to one negotiated session.""" + client_nonce = _validate_nonce(client_nonce, "client nonce") + session_nonce = _validate_nonce(session_nonce, "session nonce") + if session_nonce == ZERO_NONCE: + raise ProtocolError("session nonce must not be zero") + try: + direction = bytes(direction) + except (TypeError, ValueError): + raise ProtocolError("invalid session key direction") from None + if direction not in _SESSION_DIRECTIONS: + raise ProtocolError("invalid session key direction") + material = ( + b"traffic-masking/session/v1" + + client_nonce + + session_nonce + + direction + ) + return hmac.new(_validate_key(base_key), material, hashlib.sha256).digest() + + +def _cookie_material(address, client_nonce, session_nonce, body): + host = str(address[0]).encode("utf-8") + port = int(address[1]) + if len(host) > 65_535 or not 0 <= port <= 65_535: + raise ProtocolError("invalid source address") + return ( + b"traffic-masking/cookie/v1" + + struct.pack("!H", len(host)) + + host + + struct.pack("!H", port) + + client_nonce + + session_nonce + + body + ) + + +def create_cookie( + secret, + address, + client_nonce, + session_nonce, + hello_sequence, + expires_at, +): + """Create an opaque cookie bound to a source address and handshake values.""" + secret = _validate_key(secret) + client_nonce = _validate_nonce(client_nonce, "client nonce") + session_nonce = _validate_nonce(session_nonce, "session nonce") + if not 0 <= int(hello_sequence) < 2**64: + raise ProtocolError("hello sequence is out of range") + if not 0 <= int(expires_at) < 2**64: + raise ProtocolError("cookie expiry is out of range") + body = _COOKIE_BODY.pack(int(expires_at), int(hello_sequence)) + tag = hmac.new( + secret, + _cookie_material( + address, client_nonce, session_nonce, body + ), + hashlib.sha256, + ).digest() + return body + tag + + +def verify_cookie( + cookie, + secret, + address, + client_nonce, + session_nonce, + now, + max_future_seconds, +): + """Verify a cookie and return its decoded expiry/HELLO sequence.""" + cookie = bytes(cookie) + if len(cookie) != COOKIE_SIZE: + raise ProtocolError("invalid cookie length") + body = cookie[: _COOKIE_BODY.size] + supplied_tag = cookie[_COOKIE_BODY.size :] + expected_tag = hmac.new( + _validate_key(secret), + _cookie_material( + address, + _validate_nonce(client_nonce, "client nonce"), + _validate_nonce(session_nonce, "session nonce"), + body, + ), + hashlib.sha256, + ).digest() + if not hmac.compare_digest(supplied_tag, expected_tag): + raise ProtocolError("invalid cookie authentication tag") + + expires_at, hello_sequence = _COOKIE_BODY.unpack(body) + now = int(now) + if expires_at < now: + raise ProtocolError("cookie has expired") + if expires_at > now + int(max_future_seconds): + raise ProtocolError("cookie expiry is outside the allowed window") + return Cookie(expires_at=expires_at, hello_sequence=hello_sequence) + + +def make_padding(rng, byte_source=os.urandom, minimum=0, maximum=16): + """Return authenticated variable-length padding within configured bounds.""" + if not 0 <= minimum <= maximum <= MAX_PADDING_SIZE: + raise ValueError("invalid control padding bounds") + size = rng.randint(minimum, maximum) + padding = bytes(byte_source(size)) + if len(padding) != size: + raise ValueError("byte source returned the wrong padding length") + return padding + + +def load_psk(path): + """Read a bounded PSK file and reject permissions exposed to other users.""" + if path is None: + raise ValueError("--psk-file is required unless --insecure-diagnostic is set") + path = Path(path) + try: + with path.open("rb") as handle: + metadata = os.fstat(handle.fileno()) + if not stat.S_ISREG(metadata.st_mode): + raise ValueError(f"PSK path is not a regular file: {path}") + if os.name == "posix" and stat.S_IMODE(metadata.st_mode) & 0o077: + raise ValueError( + "PSK file permissions must not grant group/other access" + ) + secret = handle.read(MAX_PSK_SIZE + 1) + except ValueError: + raise + except OSError as exc: + raise ValueError(f"cannot read PSK file {path}: {exc}") from None + if not MIN_PSK_SIZE <= len(secret) <= MAX_PSK_SIZE: + raise ValueError( + f"PSK must contain between {MIN_PSK_SIZE} and {MAX_PSK_SIZE} bytes" + ) + return secret diff --git a/traffic-masking/systemd/README.md b/traffic-masking/systemd/README.md index 38361dd..e9fa4b9 100644 --- a/traffic-masking/systemd/README.md +++ b/traffic-masking/systemd/README.md @@ -23,7 +23,19 @@ sudo python3 -m venv /opt/traffic-masking/venv sudo /opt/traffic-masking/venv/bin/pip install numpy ``` -4. Update service files to use venv Python: +4. Generate the shared key on one endpoint and securely transfer the same binary + file to the other endpoint: + +```bash +sudo install -d -o root -g root -m 0755 /etc/traffic-masking +umask 077 +openssl rand 32 > /tmp/control.psk +sudo install -o nobody -g nogroup -m 0400 /tmp/control.psk \ + /etc/traffic-masking/control.psk +rm -f /tmp/control.psk +``` + +5. Update service files to use venv Python: ```bash sudo sed -i 's|/usr/bin/python3|/opt/traffic-masking/venv/bin/python|g' \ /etc/systemd/system/traffic-masking-*.service @@ -49,7 +61,8 @@ Example override to change rate: [Service] ExecStart= ExecStart=/opt/traffic-masking/venv/bin/python /opt/traffic-masking/traffic_masking_server.py \ - --min-mbps 1 --max-mbps 5 --advanced --profile video + --min-mbps 1 --max-mbps 5 --advanced --profile video \ + --psk-file /etc/traffic-masking/control.psk ``` ### Client Configuration @@ -118,6 +131,14 @@ Both services include security hardening: - Read-only system directories - No new privileges - Resource limits +- HMAC-SHA256 authenticated enrollment and session traffic +- A mode `0400` PSK file that is never exposed in process arguments or logs + +### Key Rotation + +The protocol does not support overlapping keys. Stop both services, install a +new mode `0400` key at `/etc/traffic-masking/control.psk` on both endpoints, then +start both services. A client with an old or incorrect key remains unregistered. ## Troubleshooting @@ -125,6 +146,7 @@ Both services include security hardening: - Check logs: `sudo journalctl -u traffic-masking-server.service -e` - Verify Python path: `which python3` - Check permissions: `ls -la /opt/traffic-masking/` +- Check PSK ownership/mode: `sudo stat /etc/traffic-masking/control.psk` ### High CPU Usage - Reduce `--entropy` to 0.5-0.7 @@ -134,4 +156,5 @@ Both services include security hardening: ### Connection Issues - Check firewall: `sudo ufw status` - Verify server is listening: `sudo ss -ulnp | grep 8888` -- Test connectivity: `nc -u -v SERVER_IP 8888` +- Run an authenticated client and inspect its status output; arbitrary UDP probes + are intentionally ignored. diff --git a/traffic-masking/systemd/traffic-masking-client.service b/traffic-masking/systemd/traffic-masking-client.service index a63291d..a26b869 100644 --- a/traffic-masking/systemd/traffic-masking-client.service +++ b/traffic-masking/systemd/traffic-masking-client.service @@ -14,7 +14,8 @@ WorkingDirectory=/opt/traffic-masking # Environment Environment="PYTHONUNBUFFERED=1" -Environment="SERVER_IP=127.0.0.1" # Override with drop-in file +# Override SERVER_IP with a drop-in file. +Environment="SERVER_IP=127.0.0.1" # Maximum configuration with advanced features ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_client.py \ @@ -27,7 +28,8 @@ ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_client.py \ --padding random \ --mtu 1200 \ --entropy 1.0 \ - --stats-interval 10 + --stats-interval 10 \ + --psk-file /etc/traffic-masking/control.psk # Restart policy Restart=always diff --git a/traffic-masking/systemd/traffic-masking-server.service b/traffic-masking/systemd/traffic-masking-server.service index deba773..54057cb 100644 --- a/traffic-masking/systemd/traffic-masking-server.service +++ b/traffic-masking/systemd/traffic-masking-server.service @@ -27,7 +27,11 @@ ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py \ --padding random \ --mtu 1200 \ --entropy 1.0 \ - --stats-interval 10 + --stats-interval 10 \ + --psk-file /etc/traffic-masking/control.psk \ + --max-clients 8 \ + --max-total-mbps 80 \ + --max-handshakes-per-second 20 # Restart policy Restart=always diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index 67d1efd..a3681ef 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -8,7 +8,8 @@ import pytest -from conftest import CLIENT, SERVER +from conftest import CLIENT, SERVER, TEST_PSK +from control_protocol import MIN_CONTROL_MTU from traffic_masking_client import AdaptiveTrafficClient from traffic_masking_server import MaskingTrafficServer @@ -22,6 +23,7 @@ {"min_mbps": 2}, # only one of the pair {"max_mbps": 8}, # only one of the pair {"target_mbps": 5, "mtu": 0}, + {"target_mbps": 5, "mtu": MIN_CONTROL_MTU - 1}, {"target_mbps": 5, "entropy": 1.5}, {"target_mbps": 5, "stats_interval": 0}, {"target_mbps": float("nan")}, @@ -29,15 +31,17 @@ {"min_mbps": float("nan"), "max_mbps": 8}, {"target_mbps": 5, "stats_interval": float("inf")}, {"target_mbps": 5, "mtu": float("inf")}, + {"target_mbps": 5, "max_clients": 1.5}, + {"target_mbps": 5, "max_handshakes_per_second": 1.5}, ], ) def test_server_rejects_bad_config(kwargs): with pytest.raises(ValueError): - MaskingTrafficServer(**kwargs) + MaskingTrafficServer(psk=TEST_PSK, **kwargs) def test_server_accepts_valid_floating_config(): - server = MaskingTrafficServer(min_mbps=2, max_mbps=8) + server = MaskingTrafficServer(min_mbps=2, max_mbps=8, psk=TEST_PSK) # 5 Mbps midpoint -> 625_000 bytes/s (decimal). assert server.target_bytes_per_second == 625_000 @@ -49,19 +53,21 @@ def test_server_accepts_valid_floating_config(): {"response_ratio": -0.1}, {"entropy": 2.0}, {"mtu": 0}, + {"mtu": MIN_CONTROL_MTU - 1}, {"stats_interval": -1}, {"response_ratio": float("nan")}, {"stats_interval": float("inf")}, {"mtu": float("inf")}, + {"mtu": 1200.5}, ], ) def test_client_rejects_bad_config(kwargs): with pytest.raises(ValueError): - AdaptiveTrafficClient("127.0.0.1", 8888, **kwargs) + AdaptiveTrafficClient("127.0.0.1", 8888, psk=TEST_PSK, **kwargs) def test_client_default_response_is_download_only(): - client = AdaptiveTrafficClient("127.0.0.1", 8888) + client = AdaptiveTrafficClient("127.0.0.1", 8888, psk=TEST_PSK) assert client.response_ratio == 0.0 @@ -77,18 +83,40 @@ def _run(script, *args): @pytest.mark.parametrize( ("script", "args", "message"), [ - (SERVER, ("--mbps", "0"), "positive finite number"), - (SERVER, ("--mbps", "nan"), "positive finite number"), - (SERVER, ("--min-mbps", "2"), "must be given together"), - (SERVER, ("--stats-interval", "inf"), "positive finite number"), + ( + SERVER, + ("--insecure-diagnostic", "--mbps", "0"), + "positive finite number", + ), + ( + SERVER, + ("--insecure-diagnostic", "--mbps", "nan"), + "positive finite number", + ), + ( + SERVER, + ("--insecure-diagnostic", "--min-mbps", "2"), + "must be given together", + ), + ( + SERVER, + ("--insecure-diagnostic", "--stats-interval", "inf"), + "positive finite number", + ), ( CLIENT, - ("--server", "127.0.0.1", "--response", "2"), + ( + "--server", "127.0.0.1", "--insecure-diagnostic", + "--response", "2", + ), "response ratio must be in [0.0, 1.0]", ), ( CLIENT, - ("--server", "127.0.0.1", "--stats-interval", "nan"), + ( + "--server", "127.0.0.1", "--insecure-diagnostic", + "--stats-interval", "nan", + ), "stats-interval must be a positive finite number", ), ], @@ -97,3 +125,28 @@ def test_invalid_cli_exits_2_with_useful_message(script, args, message): result = _run(script, *args) assert result.returncode == 2 assert message in result.stderr + + +@pytest.mark.parametrize("script", [SERVER, CLIENT]) +def test_cli_requires_psk_unless_diagnostic(script): + args = [] if script == SERVER else ["--server", "127.0.0.1"] + result = _run(script, *args) + assert result.returncode == 2 + assert "--psk-file is required" in result.stderr + + +def test_server_rejects_limits_below_per_client_rate(): + with pytest.raises(ValueError, match="max-total-mbps"): + MaskingTrafficServer(target_mbps=5, max_total_mbps=4, psk=TEST_PSK) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: MaskingTrafficServer(psk=32), + lambda: AdaptiveTrafficClient("127.0.0.1", 8888, psk=32), + ], +) +def test_constructors_reject_non_byte_psks(factory): + with pytest.raises(ValueError, match="psk must be bytes"): + factory() diff --git a/traffic-masking/test_control_protocol.py b/traffic-masking/test_control_protocol.py new file mode 100644 index 0000000..a6a658a --- /dev/null +++ b/traffic-masking/test_control_protocol.py @@ -0,0 +1,541 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Binary control framing, authentication, cookies and PSK validation.""" + +import struct +import time + +import pytest + +from control_protocol import ( + CLIENT_TO_SERVER, + COOKIE_SIZE, + FRAME_OVERHEAD, + HEADER_SIZE, + MAX_PADDING_SIZE, + MAX_PAYLOAD_SIZE, + MAX_PSK_SIZE, + MIN_CONTROL_MTU, + NONCE_SIZE, + MessageType, + ProtocolError, + SERVER_TO_CLIENT, + create_cookie, + decode_frame, + derive_session_key, + encode_frame, + inspect_frame, + load_psk, + make_padding, + verify_cookie, +) +from traffic_masking_server import MaskingTrafficServer +from traffic_masking_client import AdaptiveTrafficClient + +KEY = b"k" * 32 +CLIENT_NONCE = b"c" * NONCE_SIZE +SESSION_NONCE = b"s" * NONCE_SIZE + + +@pytest.mark.parametrize("message_type", list(MessageType)) +def test_frame_round_trip_for_every_message_type(message_type): + encoded = encode_frame( + message_type, + CLIENT_NONCE, + SESSION_NONCE, + 42, + KEY, + payload=b"payload", + padding=b"pad", + ) + decoded = decode_frame(encoded, KEY) + assert decoded.message_type is message_type + assert decoded.client_nonce == CLIENT_NONCE + assert decoded.session_nonce == SESSION_NONCE + assert decoded.sequence == 42 + assert decoded.payload == b"payload" + assert decoded.padding == b"pad" + assert len(encoded) == FRAME_OVERHEAD + len(b"payloadpad") + + +@pytest.mark.parametrize("cut", [0, 1, FRAME_OVERHEAD - 1]) +def test_truncated_frame_is_rejected(cut): + encoded = encode_frame( + MessageType.HELLO, CLIENT_NONCE, SESSION_NONCE, 1, KEY + ) + with pytest.raises(ProtocolError, match="truncated|length"): + decode_frame(encoded[:cut], KEY) + + +def test_unknown_version_and_type_are_rejected(): + encoded = bytearray( + encode_frame(MessageType.HELLO, CLIENT_NONCE, SESSION_NONCE, 1, KEY) + ) + encoded[4] = 99 + with pytest.raises(ProtocolError, match="version"): + inspect_frame(encoded) + + encoded[4] = 1 + encoded[5] = 99 + with pytest.raises(ProtocolError, match="message type"): + inspect_frame(encoded) + + +def test_corrupt_tag_is_rejected(): + encoded = bytearray( + encode_frame(MessageType.DATA, CLIENT_NONCE, SESSION_NONCE, 7, KEY) + ) + encoded[-1] ^= 1 + with pytest.raises(ProtocolError, match="authentication tag"): + decode_frame(encoded, KEY) + + +def test_keys_and_nonces_must_be_byte_strings(): + with pytest.raises(ProtocolError, match="client nonce must be bytes"): + encode_frame(MessageType.HELLO, NONCE_SIZE, SESSION_NONCE, 1, KEY) + with pytest.raises(ProtocolError, match="key must be bytes"): + encode_frame(MessageType.HELLO, CLIENT_NONCE, SESSION_NONCE, 1, 32) + + +def test_oversized_declared_lengths_are_rejected(): + encoded = bytearray( + encode_frame(MessageType.DATA, CLIENT_NONCE, SESSION_NONCE, 7, KEY) + ) + payload_length_offset = HEADER_SIZE - 4 + struct.pack_into("!H", encoded, payload_length_offset, MAX_PAYLOAD_SIZE + 1) + with pytest.raises(ProtocolError, match="payload is too large"): + inspect_frame(encoded) + + encoded = bytearray( + encode_frame(MessageType.DATA, CLIENT_NONCE, SESSION_NONCE, 7, KEY) + ) + padding_length_offset = HEADER_SIZE - 2 + struct.pack_into("!H", encoded, padding_length_offset, MAX_PADDING_SIZE + 1) + with pytest.raises(ProtocolError, match="padding is too large"): + inspect_frame(encoded) + + +def test_cookie_binds_address_nonce_sequence_and_expiry(): + cookie = create_cookie( + KEY, + ("127.0.0.1", 12345), + CLIENT_NONCE, + SESSION_NONCE, + hello_sequence=10, + expires_at=110, + ) + assert len(cookie) == COOKIE_SIZE + decoded = verify_cookie( + cookie, + KEY, + ("127.0.0.1", 12345), + CLIENT_NONCE, + SESSION_NONCE, + now=100, + max_future_seconds=10, + ) + assert decoded.hello_sequence == 10 + assert decoded.expires_at == 110 + + with pytest.raises(ProtocolError, match="authentication tag"): + verify_cookie( + cookie, + KEY, + ("127.0.0.1", 54321), + CLIENT_NONCE, + SESSION_NONCE, + now=100, + max_future_seconds=10, + ) + with pytest.raises(ProtocolError, match="expired"): + verify_cookie( + cookie, + KEY, + ("127.0.0.1", 12345), + CLIENT_NONCE, + SESSION_NONCE, + now=111, + max_future_seconds=10, + ) + + +def test_session_key_is_bound_to_both_nonces(): + key = derive_session_key( + KEY, CLIENT_NONCE, SESSION_NONCE, CLIENT_TO_SERVER + ) + assert key != derive_session_key( + KEY, b"d" * NONCE_SIZE, SESSION_NONCE, CLIENT_TO_SERVER + ) + assert key != derive_session_key( + KEY, CLIENT_NONCE, b"t" * NONCE_SIZE, CLIENT_TO_SERVER + ) + assert key != derive_session_key( + KEY, CLIENT_NONCE, SESSION_NONCE, SERVER_TO_CLIENT + ) + with pytest.raises(ProtocolError, match="direction"): + derive_session_key(KEY, CLIENT_NONCE, SESSION_NONCE, b"sideways") + + +class FixedRng: + def __init__(self, value): + self.value = value + + def randint(self, minimum, maximum): + assert minimum <= self.value <= maximum + return self.value + + +def test_control_padding_respects_deterministic_bounds(): + padding = make_padding( + FixedRng(7), byte_source=lambda size: b"p" * size, minimum=4, maximum=9 + ) + assert padding == b"p" * 7 + + +def test_psk_file_requires_length_and_restrictive_permissions(tmp_path): + psk_file = tmp_path / "control.psk" + psk_file.write_bytes(KEY) + psk_file.chmod(0o600) + assert load_psk(psk_file) == KEY + + psk_file.chmod(0o644) + with pytest.raises(ValueError, match="permissions"): + load_psk(psk_file) + + psk_file.chmod(0o600) + psk_file.write_bytes(b"short") + with pytest.raises(ValueError, match="between"): + load_psk(psk_file) + + psk_file.write_bytes(b"x" * (MAX_PSK_SIZE + 1)) + with pytest.raises(ValueError, match="between"): + load_psk(psk_file) + + with pytest.raises(ValueError, match="regular file|cannot read"): + load_psk(tmp_path) + + +class FakeClock: + def __init__(self, now=100.0): + self.now = now + + def __call__(self): + return self.now + + +class RecordingSocket: + def __init__(self): + self.sent = [] + + def sendto(self, datagram, address): + self.sent.append((bytes(datagram), address)) + return len(datagram) + + +def make_server(clock=None, **kwargs): + server = MaskingTrafficServer( + target_mbps=1, + psk=KEY, + clock=clock or FakeClock(), + rng=FixedRng(0), + byte_source=lambda size: b"n" * size, + cookie_secret=b"z" * 32, + **kwargs, + ) + server.socket = RecordingSocket() + return server + + +def complete_handshake(server, address, client_nonce=CLIENT_NONCE, hello_sequence=10): + hello = encode_frame( + MessageType.HELLO, + client_nonce, + bytes(NONCE_SIZE), + hello_sequence, + KEY, + ) + assert server.handle_datagram(hello, address) + challenge_datagram = server.socket.sent[-1][0] + challenge = decode_frame(challenge_datagram, KEY) + assert challenge.message_type is MessageType.CHALLENGE + + client_to_server_key = derive_session_key( + KEY, client_nonce, challenge.session_nonce, CLIENT_TO_SERVER + ) + server_to_client_key = derive_session_key( + KEY, client_nonce, challenge.session_nonce, SERVER_TO_CLIENT + ) + auth = encode_frame( + MessageType.AUTH, + client_nonce, + challenge.session_nonce, + hello_sequence + 1, + KEY, + payload=challenge.payload, + ) + assert server.handle_datagram(auth, address) + accept = decode_frame(server.socket.sent[-1][0], server_to_client_key) + assert accept.message_type is MessageType.ACCEPT + return ( + auth, + client_to_server_key, + server_to_client_key, + challenge.session_nonce, + ) + + +def test_unknown_and_unauthenticated_datagrams_never_register_client(): + server = make_server() + address = ("127.0.0.1", 20001) + assert not server.handle_datagram(b"x", address) + assert not server.handle_datagram(b"\x06", address) + unauthenticated_keepalive = encode_frame( + MessageType.KEEPALIVE, + CLIENT_NONCE, + SESSION_NONCE, + 1, + KEY, + ) + assert not server.handle_datagram(unauthenticated_keepalive, address) + assert server.clients == {} + assert server.socket.sent == [] + + +def test_valid_handshake_keepalive_and_replay_protection(): + clock = FakeClock() + server = make_server(clock=clock) + address = ("127.0.0.1", 20002) + auth, client_to_server_key, _, session_nonce = complete_handshake( + server, address + ) + assert address in server.clients + assert not server.handle_datagram(auth, address) + + keepalive = encode_frame( + MessageType.KEEPALIVE, + CLIENT_NONCE, + session_nonce, + 12, + client_to_server_key, + ) + clock.now += 5 + assert server.handle_datagram(keepalive, address) + assert server.clients[address]["last_seen"] == clock.now + assert not server.handle_datagram(keepalive, address) + + +def test_expired_auth_fails_and_prevalidation_is_non_amplifying(): + clock = FakeClock() + server = make_server(clock=clock) + address = ("127.0.0.1", 20003) + hello = encode_frame( + MessageType.HELLO, CLIENT_NONCE, bytes(NONCE_SIZE), 20, KEY + ) + assert server.handle_datagram(hello, address) + challenge = decode_frame(server.socket.sent[-1][0], KEY) + received, replied = server.prevalidation_totals(address) + assert replied <= received * 3 + + clock.now += server.cookie_ttl + 1 + auth = encode_frame( + MessageType.AUTH, + CLIENT_NONCE, + challenge.session_nonce, + 21, + KEY, + payload=challenge.payload, + ) + assert not server.handle_datagram(auth, address) + assert address not in server.clients + received, replied = server.prevalidation_totals(address) + assert replied <= received * 3 + + +@pytest.mark.parametrize( + ("max_clients", "max_total_mbps"), + [(1, 100), (2, 1)], +) +def test_client_and_total_caps_refuse_new_enrollment( + max_clients, max_total_mbps +): + server = make_server( + max_clients=max_clients, max_total_mbps=max_total_mbps + ) + complete_handshake(server, ("127.0.0.1", 20004)) + + second_nonce = b"d" * NONCE_SIZE + hello = encode_frame( + MessageType.HELLO, second_nonce, bytes(NONCE_SIZE), 30, KEY + ) + second_address = ("127.0.0.1", 20005) + assert server.handle_datagram(hello, second_address) + challenge = decode_frame(server.socket.sent[-1][0], KEY) + auth = encode_frame( + MessageType.AUTH, + second_nonce, + challenge.session_nonce, + 31, + KEY, + payload=challenge.payload, + ) + assert not server.handle_datagram(auth, second_address) + assert len(server.clients) == 1 + + +def test_handshake_rate_and_pending_state_are_bounded(): + clock = FakeClock() + server = make_server( + clock=clock, + max_clients=1, + max_handshakes_per_second=1, + cookie_ttl=1, + ) + state_limit = server._handshake_state_limit + for index in range(state_limit + 3): + hello = encode_frame( + MessageType.HELLO, + index.to_bytes(NONCE_SIZE, "big"), + bytes(NONCE_SIZE), + index + 1, + KEY, + ) + server.handle_datagram(hello, ("127.0.0.1", 21000 + index)) + + assert len(server._handshake_times) <= 1 + assert len(server._prevalidation) <= state_limit + + clock.now += 1.01 + next_hello = encode_frame( + MessageType.HELLO, + b"r" * NONCE_SIZE, + bytes(NONCE_SIZE), + 100, + KEY, + ) + assert server.handle_datagram(next_hello, ("127.0.0.1", 22000)) + + +def test_authenticated_framing_keeps_up_with_configured_rate(): + server = make_server() + payload = b"d" * server.data_payload_ceiling + iterations = 2_000 + + started = time.perf_counter() + for sequence in range(iterations): + datagram = encode_frame( + MessageType.DATA, + CLIENT_NONCE, + SESSION_NONCE, + sequence, + KEY, + payload=payload, + ) + decode_frame(datagram, KEY) + elapsed = time.perf_counter() - started + + processed_mbps = iterations * server.mtu * 8 / elapsed / 1_000_000 + assert processed_mbps >= server.configured_max_mbps + + +def test_protocol_overhead_is_reserved_from_client_payload_mtu(): + client = AdaptiveTrafficClient( + "server.example", 8888, psk=KEY, advanced=True, mtu=1200 + ) + assert client.data_payload_ceiling == 1200 - FRAME_OVERHEAD + assert client.obf_cfg.mtu == client.data_payload_ceiling + assert MIN_CONTROL_MTU == FRAME_OVERHEAD + COOKIE_SIZE + 16 + + +class ClientRng: + def __init__(self, uniform_values=()): + self.uniform_values = iter(uniform_values) + + def randint(self, minimum, maximum): + return minimum + + def uniform(self, minimum, maximum): + value = next(self.uniform_values) + assert minimum <= value <= maximum + return value + + +def make_client(rng=None): + client = AdaptiveTrafficClient( + "server.example", + 8888, + psk=KEY, + rng=rng or ClientRng(), + byte_source=lambda size: b"c" * size, + ) + client.socket = RecordingSocket() + client.server_addr = ("192.0.2.10", 8888) + client._reset_protocol_state() + return client + + +def test_client_ignores_unexpected_source_and_only_accepts_authenticated_data(): + client = make_client() + session_nonce = b"s" * NONCE_SIZE + challenge = encode_frame( + MessageType.CHALLENGE, + client.client_nonce, + session_nonce, + client.handshake_sequence, + KEY, + payload=b"opaque-cookie", + ) + assert client._process_datagram(challenge, ("192.0.2.11", 8888)) is None + assert client.socket.sent == [] + + assert client._process_datagram(challenge, client.server_addr) is None + assert len(client.socket.sent) == 1 + client_to_server_key = derive_session_key( + KEY, client.client_nonce, session_nonce, CLIENT_TO_SERVER + ) + server_to_client_key = derive_session_key( + KEY, client.client_nonce, session_nonce, SERVER_TO_CLIENT + ) + accept = encode_frame( + MessageType.ACCEPT, + client.client_nonce, + session_nonce, + 0, + server_to_client_key, + ) + assert client._process_datagram(accept, client.server_addr) is None + assert client.handshake_accepted + assert not client.connected + + reflected_client_data = encode_frame( + MessageType.DATA, + client.client_nonce, + session_nonce, + client.control_send_sequence + 1, + client_to_server_key, + payload=b"reflected-client-data", + ) + assert client._process_datagram( + reflected_client_data, client.server_addr + ) is None + + data = encode_frame( + MessageType.DATA, + client.client_nonce, + session_nonce, + 1, + server_to_client_key, + payload=b"cover-data", + ) + assert client._process_datagram(data, client.server_addr) == b"cover-data" + assert client._process_datagram(data, client.server_addr) is None + + forged = bytearray(data) + forged[-1] ^= 1 + assert client._process_datagram(forged, client.server_addr) is None + + +def test_keepalive_jitter_stays_within_configured_bounds(): + client = make_client(rng=ClientRng(uniform_values=(-0.2, 0.2))) + assert client._next_keepalive_delay() == pytest.approx(4.0) + assert client._next_keepalive_delay() == pytest.approx(6.0) diff --git a/traffic-masking/test_imports.py b/traffic-masking/test_imports.py index 0c26fee..de35262 100644 --- a/traffic-masking/test_imports.py +++ b/traffic-masking/test_imports.py @@ -8,6 +8,7 @@ import pytest CORE_MODULES = [ + "control_protocol", "masking_lib", "traffic_masking_server", "traffic_masking_client", diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index 7152597..1b1193d 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -10,33 +10,44 @@ """ import re +import socket import time import pytest -from conftest import CLIENT, last_match, read_log, stop_process, wait_for +from conftest import CLIENT, TEST_PSK, last_match, read_log, stop_process, wait_for +from control_protocol import ( + NONCE_SIZE, + ZERO_NONCE, + MessageType, + decode_frame, + encode_frame, +) pytestmark = pytest.mark.live -def _server_args(port, lo=2, hi=4): +def _server_args(port, psk_file, lo=2, hi=4): return [ "--host", "127.0.0.1", "--port", str(port), "--min-mbps", str(lo), "--max-mbps", str(hi), "--advanced", "--profile", "mixed", "--stats-interval", "1", + "--psk-file", str(psk_file), ] -def test_transmission_bidirectional(spawn, start_server): +def test_transmission_bidirectional(spawn, start_server, psk_file): """Client connects, receives downlink and emits uplink; server sees the client.""" - server, port = start_server(_server_args, "server") + server, port = start_server( + lambda selected_port: _server_args(selected_port, psk_file), "server" + ) client = spawn( CLIENT, [ "--server", "127.0.0.1", "--port", str(port), "--response", "0.3", "--advanced", "--uplink-profile", "mixed", - "--stats-interval", "1", + "--stats-interval", "1", "--psk-file", str(psk_file), ], "client", ) @@ -52,14 +63,17 @@ def test_transmission_bidirectional(spawn, start_server): assert tx is not None and tx > 0.0, read_log(client) -def test_reconnection_after_server_restart(spawn, start_server): +def test_reconnection_after_server_restart(spawn, start_server, psk_file): """Three-phase: connected -> server down (no false success) -> restarted -> resumed.""" - server, port = start_server(_server_args, "server1") + def server_args(selected_port): + return _server_args(selected_port, psk_file) + + server, port = start_server(server_args, "server1") client = spawn( CLIENT, ["--server", "127.0.0.1", "--port", str(port), "--response", "0.3", - "--stats-interval", "1"], + "--stats-interval", "1", "--psk-file", str(psk_file)], "client", ) assert wait_for(client, "Rx:", 10.0), read_log(client) @@ -73,14 +87,14 @@ def test_reconnection_after_server_restart(spawn, start_server): # Phase 3: restart the server; the client must reconnect. reconnect_offset = client.mark_log() - server2, _ = start_server(_server_args, "server2", port=port) + server2, _ = start_server(server_args, "server2", port=port) assert wait_for( client, "Reconnected successfully", 25.0, offset=reconnect_offset ), read_log(client, offset=reconnect_offset) assert server2.process.poll() is None -def test_fixed_rate_is_not_inflated(spawn, start_server): +def test_fixed_rate_is_not_inflated(spawn, start_server, psk_file): """Characterization: --mbps 1 emits on the order of 1 Mbit/s, not ~8.8. The legacy pattern generator legitimately scales the commanded rate @@ -91,13 +105,17 @@ def test_fixed_rate_is_not_inflated(spawn, start_server): lambda selected_port: [ "--host", "127.0.0.1", "--port", str(selected_port), "--mbps", "1", "--stats-interval", "1", + "--psk-file", str(psk_file), ], "server", ) client = spawn( CLIENT, - ["--server", "127.0.0.1", "--port", str(port), "--stats-interval", "1"], + [ + "--server", "127.0.0.1", "--port", str(port), + "--stats-interval", "1", "--psk-file", str(psk_file), + ], "client", ) assert wait_for(client, "Rx:", 10.0), read_log(client) @@ -113,7 +131,7 @@ def test_fixed_rate_is_not_inflated(spawn, start_server): assert max(rates) <= 5.0, rates -def test_floating_rate_stays_within_bounds(spawn, start_server): +def test_floating_rate_stays_within_bounds(spawn, start_server, psk_file): """Characterization: the emitted server rate stays within a slack of [min,max]. (The old realistic-pattern runner's boundary-coverage "quality" scoring is @@ -122,12 +140,16 @@ def test_floating_rate_stays_within_bounds(spawn, start_server): """ lo, hi = 2.0, 6.0 server, port = start_server( - lambda selected_port: _server_args(selected_port, lo, hi), "server" + lambda selected_port: _server_args(selected_port, psk_file, lo, hi), + "server", ) client = spawn( CLIENT, - ["--server", "127.0.0.1", "--port", str(port), "--stats-interval", "1"], + [ + "--server", "127.0.0.1", "--port", str(port), + "--stats-interval", "1", "--psk-file", str(psk_file), + ], "client", ) assert wait_for(client, "Rx:", 10.0), read_log(client) @@ -141,3 +163,69 @@ def test_floating_rate_stays_within_bounds(spawn, start_server): assert min(rates) >= 0.0 # Generous slack: this only guards against runaway rate, not shape quality. assert max(rates) <= hi * 1.75, rates + + +def test_raw_probe_gets_only_bounded_challenge(start_server, psk_file): + server, port = start_server( + lambda selected_port: [ + "--host", "127.0.0.1", "--port", str(selected_port), + "--mbps", "1", "--stats-interval", "1", + "--psk-file", str(psk_file), + ], + "probe-server", + ) + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.bind(("127.0.0.1", 0)) + probe.settimeout(0.5) + try: + probe.sendto(b"x", ("127.0.0.1", port)) + with pytest.raises(socket.timeout): + probe.recvfrom(65535) + + hello = encode_frame( + MessageType.HELLO, + b"p" * NONCE_SIZE, + ZERO_NONCE, + 1, + TEST_PSK, + ) + probe.sendto(hello, ("127.0.0.1", port)) + challenge, source = probe.recvfrom(65535) + assert source == ("127.0.0.1", port) + assert len(challenge) <= len(hello) * 3 + assert decode_frame(challenge, TEST_PSK).message_type is MessageType.CHALLENGE + + with pytest.raises(socket.timeout): + probe.recvfrom(65535) + assert "New client connected" not in read_log(server) + finally: + probe.close() + + +def test_wrong_psk_client_remains_unregistered( + spawn, start_server, psk_file, tmp_path +): + server, port = start_server( + lambda selected_port: [ + "--host", "127.0.0.1", "--port", str(selected_port), + "--mbps", "1", "--stats-interval", "1", + "--psk-file", str(psk_file), + ], + "wrong-key-server", + ) + wrong_psk = tmp_path / "wrong.psk" + wrong_psk.write_bytes(b"w" * 32) + wrong_psk.chmod(0o600) + client = spawn( + CLIENT, + [ + "--server", "127.0.0.1", "--port", str(port), + "--stats-interval", "1", "--psk-file", str(wrong_psk), + ], + "wrong-key-client", + ) + assert wait_for(client, "Handshake HELLO sent", 5.0), read_log(client) + time.sleep(2) + assert "New client connected" not in read_log(server) + assert "Authenticated session accepted" not in read_log(client) + assert last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") == 0.0 diff --git a/traffic-masking/test_payload.py b/traffic-masking/test_payload.py index af2356c..ceed824 100644 --- a/traffic-masking/test_payload.py +++ b/traffic-masking/test_payload.py @@ -5,6 +5,7 @@ import random +from conftest import TEST_PSK from traffic_masking_client import AdaptiveTrafficClient from traffic_masking_server import PacketGenerator @@ -40,7 +41,9 @@ def test_generate_packet_guards_small_sizes(): def test_client_response_payload_is_not_deterministic(): - client = AdaptiveTrafficClient("127.0.0.1", 9) # no socket opened in __init__ + client = AdaptiveTrafficClient( + "127.0.0.1", 9, psk=TEST_PSK + ) # no socket opened in __init__ p1 = client.generate_response_packet(600) p2 = client.generate_response_packet(600) # Header is [type 1][seq 4][ts 8] = 13 bytes; the payload after must differ. @@ -48,7 +51,7 @@ def test_client_response_payload_is_not_deterministic(): def test_client_response_does_not_mutate_global_rng(): - client = AdaptiveTrafficClient("127.0.0.1", 9) + client = AdaptiveTrafficClient("127.0.0.1", 9, psk=TEST_PSK) original_state = random.getstate() try: random.seed(5678) diff --git a/traffic-masking/test_rate.py b/traffic-masking/test_rate.py index b32c4df..9508cab 100644 --- a/traffic-masking/test_rate.py +++ b/traffic-masking/test_rate.py @@ -5,6 +5,7 @@ import masking_lib import pytest +from conftest import TEST_PSK from masking_lib import ProtocolMimicry, mbps_to_bytes_per_second from traffic_masking_server import MaskingTrafficServer, _budget_bytes, _RateBudget @@ -29,15 +30,17 @@ def test_server_target_uses_decimal_conversion(): # The old loop budgeted `mbps * 1024 * 1024` *bits* as if they were bytes # and added a 1.1 fudge factor, inflating --mbps 1 to ~8.8 Mbit/s of # payload. The stored target must be plain decimal bytes per second. - server = MaskingTrafficServer(target_mbps=1) + server = MaskingTrafficServer(target_mbps=1, psk=TEST_PSK) assert server.target_bytes_per_second == 125_000 legacy_bytes_budget = 1 * 1024 * 1024 * 1.1 # what the old loop granted assert legacy_bytes_budget / server.target_bytes_per_second > 8 def test_fixed_and_floating_conversions_are_identical(): - fixed = MaskingTrafficServer(target_mbps=5) - floating = MaskingTrafficServer(min_mbps=2, max_mbps=8) # midpoint 5 + fixed = MaskingTrafficServer(target_mbps=5, psk=TEST_PSK) + floating = MaskingTrafficServer( + min_mbps=2, max_mbps=8, psk=TEST_PSK + ) # midpoint 5 assert fixed.target_bytes_per_second == floating.target_bytes_per_second diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index 68fe89b..7ca2128 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -19,13 +19,33 @@ import time import numpy as np +from control_protocol import ( + CLIENT_TO_SERVER, + CONTROL_PADDING_MAX, + FRAME_OVERHEAD, + INSECURE_DIAGNOSTIC_KEY, + MAX_DATAGRAM_SIZE, + MAX_PSK_SIZE, + MIN_CONTROL_MTU, + MIN_PSK_SIZE, + NONCE_SIZE, + ZERO_NONCE, + MessageType, + ProtocolError, + SERVER_TO_CLIENT, + decode_frame, + derive_session_key, + encode_frame, + inspect_frame, + load_psk, + make_padding, +) from masking_lib import ( ObfuscationConfig, build_obfuscator, init_udp_socket, mbps_to_bytes_per_second, parse_profile, - send_fragments, ) @@ -51,6 +71,9 @@ def __init__( stats_interval=5.0, rng=None, byte_source=None, + psk=None, + insecure_diagnostic=False, + keepalive_jitter=0.2, ): # Validate configuration up front; fail fast on invalid inputs. try: @@ -65,18 +88,54 @@ def __init__( raise ValueError("response ratio must be in [0.0, 1.0]") if not math.isfinite(entropy) or not 0.0 <= entropy <= 1.0: raise ValueError("entropy must be in [0.0, 1.0]") + original_mtu = mtu + if isinstance(original_mtu, bool): + raise ValueError("mtu must be a positive integer") try: - mtu = int(mtu) + mtu = int(original_mtu) except (TypeError, ValueError, OverflowError): raise ValueError("mtu must be a positive integer") from None - if mtu <= 0: - raise ValueError("mtu must be positive") + if not isinstance(original_mtu, str) and original_mtu != mtu: + raise ValueError("mtu must be a positive integer") + if mtu > MAX_DATAGRAM_SIZE: + raise ValueError(f"mtu must not exceed {MAX_DATAGRAM_SIZE}") + if mtu < MIN_CONTROL_MTU: + raise ValueError( + f"mtu must be at least {MIN_CONTROL_MTU} bytes " + "for authenticated control framing" + ) + if advanced and mtu - FRAME_OVERHEAD < 256: + raise ValueError( + f"mtu must be at least {FRAME_OVERHEAD + 256} bytes " + "in advanced mode" + ) if not math.isfinite(stats_interval) or stats_interval <= 0: raise ValueError("stats-interval must be a positive finite number") + try: + keepalive_jitter = float(keepalive_jitter) + except (TypeError, ValueError): + raise ValueError("keepalive jitter must be a number") from None + if not math.isfinite(keepalive_jitter) or not 0.0 <= keepalive_jitter < 1.0: + raise ValueError("keepalive jitter must be in [0.0, 1.0)") + if psk is not None and insecure_diagnostic: + raise ValueError("psk and insecure diagnostic mode are mutually exclusive") + if psk is None and not insecure_diagnostic: + raise ValueError( + "a PSK is required unless insecure diagnostic mode is explicit" + ) + if psk is not None: + if not isinstance(psk, (bytes, bytearray, memoryview)): + raise ValueError("psk must be bytes") + psk = bytes(psk) + if not MIN_PSK_SIZE <= len(psk) <= MAX_PSK_SIZE: + raise ValueError( + f"psk must contain between {MIN_PSK_SIZE} and " + f"{MAX_PSK_SIZE} bytes" + ) self.server_host = server_host self.server_port = server_port - self.server_addr = (server_host, server_port) + self.server_addr = None self.response_ratio = response_ratio # Response traffic ratio self.socket = None self.running = False @@ -94,13 +153,29 @@ def __init__( self.sequence = 0 self._rng = rng or random.Random() self._byte_source = byte_source or os.urandom + self.base_key = psk if psk is not None else INSECURE_DIAGNOSTIC_KEY + self.insecure_diagnostic = bool(insecure_diagnostic) + self.keepalive_jitter = keepalive_jitter + self.mtu = mtu + self.data_payload_ceiling = mtu - FRAME_OVERHEAD + self._send_lock = threading.Lock() + self.client_nonce = ZERO_NONCE + self.session_nonce = ZERO_NONCE + self.pending_send_key = None + self.pending_receive_key = None + self.session_send_key = None + self.session_receive_key = None + self.handshake_sequence = 0 + self.control_send_sequence = 0 + self.control_receive_sequence = -1 + self.handshake_accepted = False self.stats_interval = stats_interval # Advanced obfuscation settings self.advanced = bool(advanced) self.obf_cfg = ObfuscationConfig( padding_strategy=padding, header_mode=header, - mtu=mtu, + mtu=self.data_payload_ceiling, entropy=entropy, timing_jitter=0.002, ) @@ -114,15 +189,58 @@ def _create_socket(self): self.socket.close() except Exception: pass + addresses = socket.getaddrinfo( + self.server_host, + self.server_port, + family=socket.AF_INET, + type=socket.SOCK_DGRAM, + ) + if not addresses: + raise OSError(f"could not resolve server {self.server_host}") + self.server_addr = addresses[0][4][:2] self.socket = init_udp_socket(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) self.socket.settimeout(2.0) + self._reset_protocol_state() + + def _random_nonce(self): + nonce = bytes(self._byte_source(NONCE_SIZE)) + if len(nonce) != NONCE_SIZE: + raise ValueError("byte source returned the wrong nonce length") + return nonce if nonce != ZERO_NONCE else b"\x01" + nonce[1:] + + def _reset_protocol_state(self): + self.client_nonce = self._random_nonce() + self.session_nonce = ZERO_NONCE + self.pending_send_key = None + self.pending_receive_key = None + self.session_send_key = None + self.session_receive_key = None + self.handshake_sequence = self._rng.randint(1, 2**63 - 1) + self.control_send_sequence = self.handshake_sequence + self.control_receive_sequence = -1 + self.handshake_accepted = False + self.connected = False + + def _control_padding(self): + return make_padding( + self._rng, self._byte_source, 0, CONTROL_PADDING_MAX + ) def _send_registration(self): - """Send registration packet to the server (UDP: no delivery guarantee)""" + """Send an authenticated HELLO (UDP: no delivery guarantee).""" try: - self.socket.sendto(b"INIT_CLIENT", self.server_addr) + hello = encode_frame( + MessageType.HELLO, + self.client_nonce, + ZERO_NONCE, + self.handshake_sequence, + self.base_key, + padding=self._control_padding(), + ) + self.socket.sendto(hello, self.server_addr) print( - f"[*] Registration sent to {self.server_host}:{self.server_port}", + f"[*] Handshake HELLO sent to {self.server_addr[0]}:" + f"{self.server_addr[1]}", flush=True, ) return True @@ -130,6 +248,130 @@ def _send_registration(self): print(f"[!] Registration failed: {e}", flush=True) return False + def _process_datagram(self, datagram, addr): + """Authenticate one server datagram and return its DATA payload or None.""" + if self.server_addr is None or addr[:2] != self.server_addr: + return None + try: + inspected = inspect_frame(datagram) + except ProtocolError: + return None + + if inspected.message_type is MessageType.CHALLENGE: + try: + challenge = decode_frame(datagram, self.base_key) + except ProtocolError: + return None + if ( + challenge.client_nonce != self.client_nonce + or challenge.session_nonce == ZERO_NONCE + or challenge.sequence != self.handshake_sequence + ): + return None + self.session_nonce = challenge.session_nonce + self.pending_send_key = derive_session_key( + self.base_key, + self.client_nonce, + self.session_nonce, + CLIENT_TO_SERVER, + ) + self.pending_receive_key = derive_session_key( + self.base_key, + self.client_nonce, + self.session_nonce, + SERVER_TO_CLIENT, + ) + self.control_send_sequence = self.handshake_sequence + 1 + auth = encode_frame( + MessageType.AUTH, + self.client_nonce, + self.session_nonce, + self.control_send_sequence, + self.base_key, + payload=challenge.payload, + padding=self._control_padding(), + ) + try: + self.socket.sendto(auth, self.server_addr) + except OSError: + return None + return None + + if inspected.message_type is MessageType.ACCEPT: + if self.pending_receive_key is None or self.pending_send_key is None: + return None + try: + accept = decode_frame(datagram, self.pending_receive_key) + except ProtocolError: + return None + if ( + accept.client_nonce != self.client_nonce + or accept.session_nonce != self.session_nonce + or accept.sequence != 0 + ): + return None + self.session_send_key = self.pending_send_key + self.session_receive_key = self.pending_receive_key + self.pending_send_key = None + self.pending_receive_key = None + self.control_receive_sequence = accept.sequence + self.handshake_accepted = True + print("[*] Authenticated session accepted", flush=True) + return None + + if inspected.message_type is not MessageType.DATA: + return None + if not self.handshake_accepted or self.session_receive_key is None: + return None + if ( + inspected.client_nonce != self.client_nonce + or inspected.session_nonce != self.session_nonce + ): + return None + try: + frame = decode_frame(datagram, self.session_receive_key) + except ProtocolError: + return None + if frame.sequence <= self.control_receive_sequence: + return None + self.control_receive_sequence = frame.sequence + return frame.payload + + def _send_session_message(self, message_type, payload=b""): + if not self.handshake_accepted or self.session_send_key is None: + return False + with self._send_lock: + self.control_send_sequence += 1 + datagram = encode_frame( + message_type, + self.client_nonce, + self.session_nonce, + self.control_send_sequence, + self.session_send_key, + payload=payload, + padding=( + self._control_padding() + if message_type is MessageType.KEEPALIVE + else b"" + ), + ) + try: + sent = self.socket.sendto(datagram, self.server_addr) + except OSError as exc: + print(f"[!] Send error: {exc}", flush=True) + return False + if sent != len(datagram): + return False + self.stats["bytes_sent"] += sent + self.stats["packets_sent"] += 1 + return True + + def _next_keepalive_delay(self): + factor = 1.0 + self._rng.uniform( + -self.keepalive_jitter, self.keepalive_jitter + ) + return self.KEEPALIVE_INTERVAL * factor + def _wait_for_server(self, timeout=5.0): """Wait for actual data from the server to confirm connection""" deadline = time.time() + timeout @@ -171,9 +413,12 @@ def connect(self): self.running = True print( - f"[*] Traffic masking client connecting to {self.server_host}:{self.server_port}", + f"[*] Traffic masking client connecting to {self.server_addr[0]}:" + f"{self.server_addr[1]}", flush=True, ) + auth_mode = "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" + print(f"[*] Control authentication: {auth_mode}", flush=True) # Initialize obfuscator in advanced mode if self.advanced: self.obfuscator = build_obfuscator(self.obf_cfg) @@ -229,17 +474,10 @@ def send_packet(self, packet): if delay > 0: time.sleep(delay) - def _on_sent(n: int): - self.stats["bytes_sent"] += n - self.stats["packets_sent"] += 1 - - send_fragments( - self.socket, self.server_addr, fragments, on_sent=_on_sent - ) + for fragment in fragments: + self._send_session_message(MessageType.DATA, fragment) else: - self.socket.sendto(packet, self.server_addr) - self.stats["bytes_sent"] += len(packet) - self.stats["packets_sent"] += 1 + self._send_session_message(MessageType.DATA, packet) except Exception as e: print(f"[!] Send error: {e}", flush=True) @@ -250,7 +488,11 @@ def receive_loop(self): while self.running: try: - data, addr = self.socket.recvfrom(65535) + data, addr = self.socket.recvfrom(MAX_DATAGRAM_SIZE) + + payload = self._process_datagram(data, addr) + if payload is None: + continue self.last_received = time.time() self.connected = True @@ -270,7 +512,7 @@ def receive_loop(self): # Occasionally send echo to simulate interactivity if random.random() < 0.01: # 1% probability - echo_packet = self.generate_response_packet(len(data) // 4) + echo_packet = self.generate_response_packet(len(payload) // 4) self.send_packet(echo_packet) except socket.timeout: @@ -283,7 +525,7 @@ def receive_loop(self): def keepalive_loop(self): """Send periodic keepalives and handle reconnection""" while self.running: - time.sleep(self.KEEPALIVE_INTERVAL) + time.sleep(self._next_keepalive_delay()) if not self.running: break @@ -302,11 +544,10 @@ def keepalive_loop(self): # After _reconnect returns (success), resume keepalive loop continue - # Send keepalive to stay registered on the server - try: - self.socket.sendto(b"KEEPALIVE", self.server_addr) - except Exception: - pass + if self.handshake_accepted: + self._send_session_message(MessageType.KEEPALIVE) + else: + self._send_registration() def send_loop(self): """Generate uplink traffic""" @@ -412,10 +653,21 @@ def main(): default=5.0, help="Stats print interval in seconds", ) + auth_group = parser.add_mutually_exclusive_group() + auth_group.add_argument( + "--psk-file", + help="Path to a 32+ byte pre-shared key file (never pass the key itself)", + ) + auth_group.add_argument( + "--insecure-diagnostic", + action="store_true", + help="Run without a secret; diagnostic use only", + ) args = parser.parse_args() try: + psk = None if args.insecure_diagnostic else load_psk(args.psk_file) client = AdaptiveTrafficClient( args.server, args.port, @@ -427,6 +679,8 @@ def main(): mtu=args.mtu, entropy=args.entropy, stats_interval=args.stats_interval, + psk=psk, + insecure_diagnostic=args.insecure_diagnostic, ) except ValueError as exc: parser.error(str(exc)) diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index 9d98146..b55f80f 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -19,8 +19,32 @@ import struct import threading import time +from collections import OrderedDict, deque import numpy as np +from control_protocol import ( + CLIENT_TO_SERVER, + CONTROL_PADDING_MAX, + FRAME_OVERHEAD, + INSECURE_DIAGNOSTIC_KEY, + MAX_DATAGRAM_SIZE, + MAX_PSK_SIZE, + MIN_CONTROL_MTU, + MIN_PSK_SIZE, + NONCE_SIZE, + ZERO_NONCE, + MessageType, + ProtocolError, + SERVER_TO_CLIENT, + create_cookie, + decode_frame, + derive_session_key, + encode_frame, + inspect_frame, + load_psk, + make_padding, + verify_cookie, +) from masking_lib import ( DynamicObfuscator, TrafficProfile, @@ -97,6 +121,20 @@ def _unit_interval_float(value, name): return value +def _positive_int(value, name): + if isinstance(value, bool): + raise ValueError(f"{name} must be a positive integer") + try: + parsed = int(value) + except (TypeError, ValueError, OverflowError): + raise ValueError(f"{name} must be a positive integer") from None + if not isinstance(value, str) and value != parsed: + raise ValueError(f"{name} must be a positive integer") + if parsed <= 0: + raise ValueError(f"{name} must be a positive integer") + return parsed + + class TrafficPattern: """Generator of diverse traffic rate patterns (CBR, bursts, waves, random-walk, media-like)""" @@ -166,7 +204,7 @@ def get_current_rate(self, base_rate): class PacketGenerator: """Packet generator with variable sizes and pseudo-random payload characteristics""" - def __init__(self, min_size=64, max_size=1400, rng=None, byte_source=None): + def __init__(self, min_size=28, max_size=1400, rng=None, byte_source=None): self.min_size = min_size self.max_size = max_size self.sequence = 0 @@ -224,6 +262,16 @@ def __init__( mtu=1200, entropy=1.0, stats_interval=5.0, + psk=None, + insecure_diagnostic=False, + max_clients=16, + max_total_mbps=100.0, + max_handshakes_per_second=20, + cookie_ttl=10, + clock=None, + rng=None, + byte_source=None, + cookie_secret=None, ): # Validate configuration up front; fail fast on invalid rates/ranges. floating = min_mbps is not None and max_mbps is not None @@ -239,16 +287,65 @@ def __init__( target_mbps = _positive_finite_float( target_mbps, "target rate (--mbps)" ) - try: - mtu = int(mtu) - except (TypeError, ValueError, OverflowError): - raise ValueError("mtu must be a positive integer") from None - if mtu <= 0: - raise ValueError("mtu must be positive") + mtu = _positive_int(mtu, "mtu") + if mtu > MAX_DATAGRAM_SIZE: + raise ValueError(f"mtu must not exceed {MAX_DATAGRAM_SIZE}") + if mtu < MIN_CONTROL_MTU: + raise ValueError( + f"mtu must be at least {MIN_CONTROL_MTU} bytes " + "for authenticated control framing" + ) + if advanced and mtu - FRAME_OVERHEAD < 256: + raise ValueError( + f"mtu must be at least {FRAME_OVERHEAD + 256} bytes " + "in advanced mode" + ) entropy = _unit_interval_float(entropy, "entropy") stats_interval = _positive_finite_float( stats_interval, "stats-interval" ) + max_clients = _positive_int(max_clients, "max-clients") + max_total_mbps = _positive_finite_float( + max_total_mbps, "max-total-mbps" + ) + max_handshakes_per_second = _positive_int( + max_handshakes_per_second, "max-handshakes-per-second" + ) + cookie_ttl = _positive_int(cookie_ttl, "cookie-ttl") + configured_max_mbps = max_mbps if floating else target_mbps + if configured_max_mbps > max_total_mbps: + raise ValueError( + "max-total-mbps must be at least the configured per-client maximum" + ) + if psk is not None and insecure_diagnostic: + raise ValueError("psk and insecure diagnostic mode are mutually exclusive") + if psk is None and not insecure_diagnostic: + raise ValueError( + "a PSK is required unless insecure diagnostic mode is explicit" + ) + if psk is not None: + if not isinstance(psk, (bytes, bytearray, memoryview)): + raise ValueError("psk must be bytes") + psk = bytes(psk) + if not MIN_PSK_SIZE <= len(psk) <= MAX_PSK_SIZE: + raise ValueError( + f"psk must contain between {MIN_PSK_SIZE} and " + f"{MAX_PSK_SIZE} bytes" + ) + + self._clock = clock or time.time + self._rng = rng or random.Random() + self._byte_source = byte_source or os.urandom + self.base_key = psk if psk is not None else INSECURE_DIAGNOSTIC_KEY + self.insecure_diagnostic = bool(insecure_diagnostic) + if cookie_secret is None: + self.cookie_secret = bytes(self._byte_source(32)) + elif isinstance(cookie_secret, (bytes, bytearray, memoryview)): + self.cookie_secret = bytes(cookie_secret) + else: + raise ValueError("cookie secret must be bytes") + if len(self.cookie_secret) < 32: + raise ValueError("cookie secret must contain at least 32 bytes") self.host = host self.port = port @@ -261,10 +358,15 @@ def __init__( else: self.target_bytes_per_second = mbps_to_bytes_per_second(target_mbps) self.socket = None - self.clients = {} # {address: {'last_seen': timestamp, 'stats': {...}}} + self.clients = {} # Only authenticated/validated sessions. self.running = False self.pattern_gen = TrafficPattern() - self.packet_gen = PacketGenerator() + self.data_payload_ceiling = mtu - FRAME_OVERHEAD + self.packet_gen = PacketGenerator( + max_size=min(1400, self.data_payload_ceiling), + rng=self._rng, + byte_source=self._byte_source, + ) self.stats = {"bytes_sent": 0, "packets_sent": 0, "start_time": time.time()} self.last_stats = {"bytes_sent": 0, "packets_sent": 0, "time": time.time()} self.stats_interval = stats_interval @@ -283,6 +385,17 @@ def __init__( self.padding_strategy = padding self.mtu = mtu self.entropy = entropy + self.max_clients = max_clients + self.max_total_mbps = max_total_mbps + self.max_handshakes_per_second = max_handshakes_per_second + self.cookie_ttl = cookie_ttl + self.configured_max_mbps = configured_max_mbps + self._handshake_times = deque() + self._prevalidation = OrderedDict() + self._accepted_auth = OrderedDict() + self._handshake_state_limit = ( + max_clients + max_handshakes_per_second * cookie_ttl + ) self.obfuscator = None self.generator = None @@ -313,7 +426,7 @@ def start(self): self.obfuscator = DynamicObfuscator( padding_strategy=self.padding_strategy, timing_jitter=0.002, - mtu=self.mtu, + mtu=self.data_payload_ceiling, header_mode=self.header_mode, ) self.generator = stream_generator( @@ -330,6 +443,12 @@ def start(self): f"[*] Advanced mode enabled: profile={self.profile.value}, header={self.header_mode}, padding={self.padding_strategy}, mtu={self.mtu}, entropy={self.entropy}", flush=True, ) + auth_mode = "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" + print( + f"[*] Control authentication: {auth_mode} | max clients: " + f"{self.max_clients} | total cap: {self.max_total_mbps} Mbps", + flush=True, + ) # Start threads threading.Thread(target=self.receive_loop, daemon=True).start() @@ -337,28 +456,232 @@ def start(self): threading.Thread(target=self.stats_loop, daemon=True).start() threading.Thread(target=self.cleanup_loop, daemon=True).start() + def _prune_handshake_state(self, now): + while self._handshake_times and self._handshake_times[0] <= now - 1.0: + self._handshake_times.popleft() + for mapping in (self._prevalidation, self._accepted_auth): + expired = [key for key, value in mapping.items() if value["expires"] < now] + for key in expired: + del mapping[key] + + def _consume_handshake_slot(self, now): + self._prune_handshake_state(now) + if len(self._handshake_times) >= self.max_handshakes_per_second: + return False + self._handshake_times.append(now) + return True + + def _record_prevalidation_input(self, addr, byte_count, now): + self._prune_handshake_state(now) + entry = self._prevalidation.get(addr) + if entry is None: + if len(self._prevalidation) >= self._handshake_state_limit: + return None + entry = {"received": 0, "replied": 0, "expires": now + self.cookie_ttl} + self._prevalidation[addr] = entry + entry["received"] += byte_count + entry["expires"] = now + self.cookie_ttl + self._prevalidation.move_to_end(addr) + return entry + + def _send_prevalidation(self, addr, datagram, entry): + if entry["replied"] + len(datagram) > entry["received"] * 3: + return False + try: + sent = self.socket.sendto(datagram, addr) + except OSError: + return False + if sent != len(datagram): + return False + entry["replied"] += sent + return True + + def prevalidation_totals(self, addr): + """Return received/replied pre-validation bytes for tests/diagnostics.""" + entry = self._prevalidation.get(addr) + if entry is None: + return 0, 0 + return entry["received"], entry["replied"] + + def _control_padding(self): + return make_padding( + self._rng, self._byte_source, 0, CONTROL_PADDING_MAX + ) + + def _random_nonce(self): + nonce = bytes(self._byte_source(NONCE_SIZE)) + if len(nonce) != NONCE_SIZE: + raise ValueError("byte source returned the wrong nonce length") + return nonce if nonce != ZERO_NONCE else b"\x01" + nonce[1:] + + def _handle_hello(self, frame, addr, entry, now): + if ( + frame.client_nonce == ZERO_NONCE + or frame.session_nonce != ZERO_NONCE + or frame.payload + or frame.sequence == 2**64 - 1 + ): + return False + session_nonce = self._random_nonce() + expires_at = int(now) + self.cookie_ttl + cookie = create_cookie( + self.cookie_secret, + addr, + frame.client_nonce, + session_nonce, + frame.sequence, + expires_at, + ) + challenge = encode_frame( + MessageType.CHALLENGE, + frame.client_nonce, + session_nonce, + frame.sequence, + self.base_key, + payload=cookie, + padding=self._control_padding(), + ) + return self._send_prevalidation(addr, challenge, entry) + + def _handle_auth(self, frame, addr, entry, now): + if frame.client_nonce == ZERO_NONCE or frame.session_nonce == ZERO_NONCE: + return False + try: + cookie = verify_cookie( + frame.payload, + self.cookie_secret, + addr, + frame.client_nonce, + frame.session_nonce, + now=int(now), + max_future_seconds=self.cookie_ttl, + ) + except ProtocolError: + return False + if frame.sequence != cookie.hello_sequence + 1: + return False + + replay_key = frame.client_nonce + frame.session_nonce + self._prune_handshake_state(now) + if replay_key in self._accepted_auth: + return False + existing = 1 if addr in self.clients else 0 + prospective_clients = len(self.clients) - existing + 1 + if prospective_clients > self.max_clients: + return False + if prospective_clients * self.configured_max_mbps > self.max_total_mbps: + return False + if len(self._accepted_auth) >= self._handshake_state_limit: + return False + + receive_key = derive_session_key( + self.base_key, + frame.client_nonce, + frame.session_nonce, + CLIENT_TO_SERVER, + ) + send_key = derive_session_key( + self.base_key, + frame.client_nonce, + frame.session_nonce, + SERVER_TO_CLIENT, + ) + accept = encode_frame( + MessageType.ACCEPT, + frame.client_nonce, + frame.session_nonce, + 0, + send_key, + padding=self._control_padding(), + ) + if not self._send_prevalidation(addr, accept, entry): + return False + + self._accepted_auth[replay_key] = { + "expires": now + self.cookie_ttl + } + self.clients[addr] = { + "last_seen": now, + "bytes_received": 0, + "packets_received": 0, + "client_nonce": frame.client_nonce, + "session_nonce": frame.session_nonce, + "receive_key": receive_key, + "send_key": send_key, + "receive_sequence": frame.sequence, + "send_sequence": 0, + } + print(f"[+] New client connected: {addr}", flush=True) + return True + + def _handle_session_frame(self, inspected, datagram, addr, now): + client = self.clients.get(addr) + if client is None: + return False + if inspected.message_type not in (MessageType.KEEPALIVE, MessageType.DATA): + return False + if ( + inspected.client_nonce != client["client_nonce"] + or inspected.session_nonce != client["session_nonce"] + ): + return False + try: + frame = decode_frame(datagram, client["receive_key"]) + except ProtocolError: + return False + if frame.sequence <= client["receive_sequence"]: + return False + + client["receive_sequence"] = frame.sequence + client["last_seen"] = now + if frame.message_type is MessageType.DATA: + client["bytes_received"] += len(datagram) + client["packets_received"] += 1 + return True + + def handle_datagram(self, datagram, addr): + """Validate and dispatch one UDP datagram; return whether it was accepted.""" + try: + inspected = inspect_frame(datagram) + except ProtocolError: + return False + now = self._clock() + if inspected.message_type in (MessageType.KEEPALIVE, MessageType.DATA): + return self._handle_session_frame(inspected, datagram, addr, now) + if inspected.message_type not in (MessageType.HELLO, MessageType.AUTH): + return False + + entry = self._record_prevalidation_input(addr, len(datagram), now) + if entry is None or not self._consume_handshake_slot(now): + return False + try: + frame = decode_frame(datagram, self.base_key) + except ProtocolError: + return False + if frame.message_type is MessageType.HELLO: + return self._handle_hello(frame, addr, entry, now) + return self._handle_auth(frame, addr, entry, now) + def receive_loop(self): - """Receive packets from clients""" + """Receive and authenticate packets from clients.""" while self.running: try: - data, addr = self.socket.recvfrom(65535) - - # Update client info - if addr not in self.clients: - print(f"[+] New client connected: {addr}", flush=True) - self.clients[addr] = { - "last_seen": time.time(), - "bytes_received": 0, - "packets_received": 0, - } - - self.clients[addr]["last_seen"] = time.time() - self.clients[addr]["bytes_received"] += len(data) - self.clients[addr]["packets_received"] += 1 - - except Exception as e: + data, addr = self.socket.recvfrom(MAX_DATAGRAM_SIZE) + self.handle_datagram(data, addr) + except Exception as exc: if self.running: - print(f"[!] Receive error: {e}", flush=True) + print(f"[!] Receive error: {exc}", flush=True) + + def _frame_data_for_client(self, client, payload): + client["send_sequence"] += 1 + return encode_frame( + MessageType.DATA, + client["client_nonce"], + client["session_nonce"], + client["send_sequence"], + client["send_key"], + payload=payload, + ) def send_loop(self): """Send cover traffic to clients""" @@ -399,12 +722,15 @@ def send_loop(self): # Send fragments and track bytes packet_bytes = 0 for frag in frags: - for addr in list(self.clients.keys()): + for addr, client in list(self.clients.items()): try: - self.socket.sendto(frag, addr) - self.stats["bytes_sent"] += len(frag) + framed = self._frame_data_for_client(client, frag) + sent = self.socket.sendto(framed, addr) + if sent != len(framed): + continue + self.stats["bytes_sent"] += sent self.stats["packets_sent"] += 1 - packet_bytes += len(frag) + packet_bytes += sent except Exception as e: print(f"[!] Send error to client {addr}: {e}", flush=True) @@ -436,22 +762,30 @@ def send_loop(self): # Send packets in batches for efficiency packets_sent_this_round = 0 while ( - bytes_available > 0 and self.clients and packets_sent_this_round < 50 + bytes_available >= FRAME_OVERHEAD + 28 + and self.clients + and packets_sent_this_round < 50 ): - # Generate larger packets for better throughput - packet_size = min(bytes_available, random.randint(1000, 1400)) - packet = self.packet_gen.generate_packet(packet_size) + target_frame_size = min( + bytes_available, self.mtu, self._rng.randint(1000, 1400) + ) + payload_size = target_frame_size - FRAME_OVERHEAD + packet = self.packet_gen.generate_packet(payload_size) + framed_size = FRAME_OVERHEAD + len(packet) # Send to all active clients - for addr in list(self.clients.keys()): + for addr, client in list(self.clients.items()): try: - self.socket.sendto(packet, addr) - self.stats["bytes_sent"] += len(packet) + framed = self._frame_data_for_client(client, packet) + sent = self.socket.sendto(framed, addr) + if sent != len(framed): + continue + self.stats["bytes_sent"] += sent self.stats["packets_sent"] += 1 except Exception as e: print(f"[!] Send error to client {addr}: {e}") - rate_budget.consume(len(packet)) + rate_budget.consume(framed_size) bytes_available = rate_budget.available packets_sent_this_round += 1 @@ -584,10 +918,39 @@ def main(): default=5.0, help="Stats print interval in seconds", ) + auth_group = parser.add_mutually_exclusive_group() + auth_group.add_argument( + "--psk-file", + help="Path to a 32+ byte pre-shared key file (never pass the key itself)", + ) + auth_group.add_argument( + "--insecure-diagnostic", + action="store_true", + help="Run without a secret; diagnostic use only", + ) + parser.add_argument( + "--max-clients", + type=int, + default=16, + help="Maximum number of authenticated clients", + ) + parser.add_argument( + "--max-total-mbps", + type=float, + default=100.0, + help="Maximum configured aggregate server egress in decimal Mbps", + ) + parser.add_argument( + "--max-handshakes-per-second", + type=int, + default=20, + help="Global cap on authenticated handshake frames per second", + ) args = parser.parse_args() try: + psk = None if args.insecure_diagnostic else load_psk(args.psk_file) server = MaskingTrafficServer( args.host, args.port, @@ -601,6 +964,11 @@ def main(): mtu=args.mtu, entropy=args.entropy, stats_interval=args.stats_interval, + psk=psk, + insecure_diagnostic=args.insecure_diagnostic, + max_clients=args.max_clients, + max_total_mbps=args.max_total_mbps, + max_handshakes_per_second=args.max_handshakes_per_second, ) except ValueError as exc: parser.error(str(exc)) From 441d97ece1815daab82bf81686f465535826ac1f Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:22:36 +0300 Subject: [PATCH 05/10] traffic-masking: separate shaping from framed pacing --- traffic-masking/EXAMPLES.md | 37 +- traffic-masking/Makefile | 4 +- traffic-masking/README.md | 29 +- traffic-masking/SUMMARY.md | 4 +- traffic-masking/masking_lib.py | 631 +++++++++--------- traffic-masking/systemd/README.md | 8 +- .../systemd/traffic-masking-server.service | 5 +- traffic-masking/test_cli.py | 41 ++ traffic-masking/test_core.py | 16 +- traffic-masking/test_live.py | 11 +- traffic-masking/test_rate.py | 70 +- traffic-masking/test_shaping.py | 148 ++++ traffic-masking/traffic_masking_client.py | 21 +- traffic-masking/traffic_masking_server.py | 491 ++++++-------- 14 files changed, 802 insertions(+), 714 deletions(-) create mode 100644 traffic-masking/test_shaping.py diff --git a/traffic-masking/EXAMPLES.md b/traffic-masking/EXAMPLES.md index d9c6d5f..731e64c 100644 --- a/traffic-masking/EXAMPLES.md +++ b/traffic-masking/EXAMPLES.md @@ -13,8 +13,8 @@ make test-fast python traffic_masking_server.py --mbps 5 --psk-file ./traffic-masking.psk python traffic_masking_client.py --server 127.0.0.1 --psk-file ./traffic-masking.psk -# Floating rate (recommended) -python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced \ +# Floating rate +python traffic_masking_server.py --shape-mode rate --min-mbps 2 --max-mbps 8 \ --psk-file ./traffic-masking.psk python traffic_masking_client.py --server 127.0.0.1 --response 0.3 --advanced \ --psk-file ./traffic-masking.psk @@ -25,22 +25,25 @@ to no scheduled uplink (`--response 0.0`). A nonzero response is an explicit diagnostic/profile choice; the current standalone scheduler does not guarantee that exact ratio on the wire. +Server `rate` mode supplies demand to reach its configured target. Experimental +`profile` mode preserves native event sizes and gaps; `--max-mbps` only caps it. + ## Use Cases ### Mask Video Calls ```bash # Google Meet / Zoom / Teams -python traffic_masking_server.py --min-mbps 1 --max-mbps 5 --advanced \ +python traffic_masking_server.py --shape-mode profile --max-mbps 5 \ --profile video --psk-file ./traffic-masking.psk # WhatsApp / Telegram voice calls -python traffic_masking_server.py --min-mbps 0.5 --max-mbps 1.5 --advanced \ +python traffic_masking_server.py --shape-mode profile --max-mbps 1.5 \ --profile voip --psk-file ./traffic-masking.psk ``` ### Mask Web Browsing ```bash -python traffic_masking_server.py --min-mbps 1 --max-mbps 4 --advanced \ +python traffic_masking_server.py --shape-mode profile --max-mbps 4 \ --profile web --header quic --psk-file ./traffic-masking.psk ``` @@ -48,8 +51,8 @@ python traffic_masking_server.py --min-mbps 1 --max-mbps 4 --advanced \ ```bash # Server python traffic_masking_server.py \ - --min-mbps 3 --max-mbps 10 \ - --advanced --profile mixed \ + --shape-mode profile --max-mbps 10 \ + --profile mixed \ --header rtp --padding random \ --entropy 1.0 --psk-file ./traffic-masking.psk @@ -65,7 +68,7 @@ python traffic_masking_client.py \ ```bash # Lower CPU usage, good throughput python traffic_masking_server.py \ - --mbps 8 --advanced --profile web \ + --shape-mode profile --max-mbps 8 --profile web \ --header none --padding none --entropy 0.7 \ --psk-file ./traffic-masking.psk ``` @@ -88,8 +91,8 @@ docker build -t traffic-masking . # Server docker run -d --name tm-server --network host \ --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_server.py --min-mbps 2 --max-mbps 8 \ - --advanced --psk-file /run/secrets/traffic-masking.psk + traffic-masking traffic_masking_server.py --shape-mode rate \ + --min-mbps 2 --max-mbps 8 --psk-file /run/secrets/traffic-masking.psk # Client docker run -d --name tm-client --network host \ @@ -105,7 +108,7 @@ services: server: build: . network_mode: host - command: traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced --psk-file /run/secrets/traffic-masking.psk + command: traffic_masking_server.py --shape-mode rate --min-mbps 2 --max-mbps 8 --psk-file /run/secrets/traffic-masking.psk volumes: - ./traffic-masking.psk:/run/secrets/traffic-masking.psk:ro restart: unless-stopped @@ -138,19 +141,19 @@ sudo sysctl -p ### Process Priority ```bash # High priority -sudo nice -n -10 python traffic_masking_server.py --min-mbps 5 --max-mbps 15 \ - --advanced --psk-file ./traffic-masking.psk +sudo nice -n -10 python traffic_masking_server.py --shape-mode rate \ + --min-mbps 5 --max-mbps 15 --psk-file ./traffic-masking.psk # CPU affinity (cores 0,1) -taskset -c 0,1 python traffic_masking_server.py --min-mbps 5 --max-mbps 15 \ - --advanced --psk-file ./traffic-masking.psk +taskset -c 0,1 python traffic_masking_server.py --shape-mode rate \ + --min-mbps 5 --max-mbps 15 --psk-file ./traffic-masking.psk ``` ### PyPy for Better Performance ```bash sudo apt-get install pypy3 pypy3 -m pip install numpy -pypy3 traffic_masking_server.py --min-mbps 5 --max-mbps 15 --advanced \ +pypy3 traffic_masking_server.py --shape-mode rate --min-mbps 5 --max-mbps 15 \ --psk-file ./traffic-masking.psk ``` @@ -181,7 +184,7 @@ python traffic_masking_client.py --server SERVER_IP \ # Debug mode with verbose output PYTHONUNBUFFERED=1 python -u traffic_masking_server.py \ - --mbps 5 --advanced --stats-interval 1 \ + --shape-mode rate --mbps 5 --stats-interval 1 \ --psk-file ./traffic-masking.psk 2>&1 | tee server.log # Network statistics diff --git a/traffic-masking/Makefile b/traffic-masking/Makefile index 18b1b24..300f9e4 100644 --- a/traffic-masking/Makefile +++ b/traffic-masking/Makefile @@ -35,8 +35,8 @@ test-live: venv ## run only the live end-to-end tests lint: venv ## static checks $(RUFF) check . -run-server: venv ## demo server (floating 2-8 Mbps, advanced) - $(PYTHON) traffic_masking_server.py --min-mbps 2 --max-mbps 8 --advanced --profile mixed --insecure-diagnostic +run-server: venv ## demo server (experimental mixed profile, capped at 8 Mbps) + $(PYTHON) traffic_masking_server.py --shape-mode profile --profile mixed --max-mbps 8 --insecure-diagnostic run-client: venv ## demo client against 127.0.0.1 $(PYTHON) traffic_masking_client.py --server 127.0.0.1 --advanced --uplink-profile mixed --insecure-diagnostic diff --git a/traffic-masking/README.md b/traffic-masking/README.md index f4dea14..bf2be21 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -47,13 +47,16 @@ python traffic_masking_client.py --server \ --psk-file ./traffic-masking.psk ``` -### Advanced Mode +### Experimental Profile Mode + +Profile mode preserves each handcrafted profile's native event volumes and +gaps. `--max-mbps` is only a ceiling; it does not raise a low-rate profile to +the cap. These profiles remain experimental pending reference-trace validation. ```bash -# Server with full obfuscation +# Server with native mixed-profile load and a 10 Mbps ceiling python traffic_masking_server.py \ - --min-mbps 3 --max-mbps 10 \ - --advanced --profile mixed \ + --shape-mode profile --profile mixed --max-mbps 10 \ --header rtp --padding random \ --psk-file ./traffic-masking.psk @@ -80,16 +83,23 @@ make test ## Key Parameters -- `--mbps`: Fixed target rate in decimal Mbps of application UDP payload -- `--min-mbps/--max-mbps`: Floating range in the same decimal Mbps unit -- `--advanced`: Enable ML-resistant features -- `--profile`: Traffic pattern (web/video/voip/file/gaming/mixed) +- `--shape-mode rate|profile`: Select an explicit offered-load contract. The + default is `rate`. +- `--mbps`: Fixed target in decimal Mbps of authenticated application datagram + bytes for rate mode (default 5) +- `--min-mbps/--max-mbps`: Floating range in rate mode +- `--profile`: Required experimental pattern in profile mode +- `--max-mbps`: In profile mode, an optional ceiling that only adds delay +- `--advanced`: Deprecated warning-emitting alias for profile mode - `--response`: Optional diagnostic/profile uplink setting (0.0-1.0, default 0.0). Nonzero values request additional uplink traffic; the current standalone scheduler does not guarantee that exact ratio on the wire. - `--header`: Pseudo-headers (none/rtp/quic) - `--padding`: Padding strategy (none/random/fixed_buckets/progressive) - `--entropy`: Payload entropy (0.0-1.0) +- `--mtu`: Maximum application UDP datagram size after protocol framing and + padding. This is application packetization, not IP fragmentation; account for + IP and outer encrypted-transport overhead when selecting a path-safe value. - `--psk-file`: Path to the shared 32-4096 byte binary key. The file must not grant group or other permissions. - `--max-clients`, `--max-total-mbps`: Bound authenticated enrollment and the @@ -120,7 +130,8 @@ restart both processes. Never log the key or put its value in a service command. docker build -t traffic-masking . docker run --network host \ --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_server.py --min-mbps 2 --max-mbps 8 \ + traffic-masking traffic_masking_server.py --shape-mode rate \ + --min-mbps 2 --max-mbps 8 \ --psk-file /run/secrets/traffic-masking.psk ``` diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index 94abcbb..8e18d6d 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -7,6 +7,8 @@ **masking_lib.py** - `stream_generator()`: Main traffic generation with fixed/floating rate support - `DynamicObfuscator`: Packet obfuscation and fragmentation +- `ShapeEvent`, `Packetizer`, `RateLimiter`: Explicit offered-load, + application-packetization, and framed-byte pacing contracts - `ProtocolMimicry`: Pattern generation for different traffic profiles - `TrafficProfile`: Enum for supported profiles (web, video, voip, file, gaming, mixed) @@ -20,7 +22,7 @@ **traffic_masking_server.py** - Multi-client UDP server with batch processing - Authenticated client enrollment with client, handshake-rate, and total-rate caps -- Adaptive rate control with floating mode +- Explicit fixed/floating rate mode and experimental native profile mode - Real-time statistics monitoring **traffic_masking_client.py** diff --git a/traffic-masking/masking_lib.py b/traffic-masking/masking_lib.py index d41b267..492baee 100644 --- a/traffic-masking/masking_lib.py +++ b/traffic-masking/masking_lib.py @@ -23,9 +23,10 @@ import socket import time import math -from enum import Enum +from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass -from typing import Iterator, List, Optional, Sequence, Tuple, Dict, Any, Union, Callable +from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Union try: @@ -48,20 +49,25 @@ __all__ = [ "TrafficProfile", "PatternStep", + "ShapeEvent", + "Packetizer", + "RateLimiter", + "RateReservation", "ProtocolMimicry", "DynamicObfuscator", "StatisticalAnalyzer", "stream_generator", + "profile_event_generator", "ObfuscationConfig", "parse_profile", "build_obfuscator", "init_udp_socket", "send_fragments", "mbps_to_bytes_per_second", + "generate_payload", ] -# Bit-rate unit is decimal megabits/s (10^6 bit/s) of application payload bytes, -# used consistently across configuration, pacing and metrics. +# Bit-rate unit is decimal megabits/s (10^6 bit/s), converted to byte budgets. _BITS_PER_MEGABIT = 1_000_000 @@ -85,6 +91,154 @@ class PatternStep: delay: float # seconds (inter-packet delay target) +@dataclass(frozen=True) +class ShapeEvent: + """One logical offered-load event before obfuscation and packetization.""" + + byte_count: int + delay: float = 0.0 + + def __post_init__(self): + if isinstance(self.byte_count, bool) or not isinstance(self.byte_count, int): + raise ValueError("event byte_count must be a non-negative integer") + if self.byte_count < 0: + raise ValueError("event byte_count must be non-negative") + if not math.isfinite(self.delay) or self.delay < 0: + raise ValueError("event delay must be a non-negative finite number") + + +class Packetizer: + """Split application bytes so final framed datagrams fit a fixed ceiling.""" + + def __init__(self, datagram_ceiling, framing_overhead=0): + if isinstance(datagram_ceiling, bool) or not isinstance( + datagram_ceiling, int + ): + raise ValueError("datagram ceiling must be a positive integer") + if isinstance(framing_overhead, bool) or not isinstance( + framing_overhead, int + ): + raise ValueError("framing overhead must be a non-negative integer") + if datagram_ceiling <= 0 or framing_overhead < 0: + raise ValueError("invalid packetizer dimensions") + if framing_overhead >= datagram_ceiling: + raise ValueError("framing overhead leaves no payload capacity") + self.datagram_ceiling = datagram_ceiling + self.framing_overhead = framing_overhead + self.payload_ceiling = datagram_ceiling - framing_overhead + + def packetize(self, payload): + if not isinstance(payload, (bytes, bytearray, memoryview)): + raise ValueError("packetizer payload must be bytes") + payload = bytes(payload) + return tuple( + payload[offset : offset + self.payload_ceiling] + for offset in range(0, len(payload), self.payload_ceiling) + ) + + +@dataclass(frozen=True) +class RateReservation: + byte_count: int + delay: float + token: int + + +class RateLimiter: + """Bounded token bucket with explicit send reservation accounting.""" + + def __init__( + self, + rate_bytes_per_second, + burst_bytes, + clock=None, + ): + self._clock = clock or time.monotonic + self._rate = self._validate_rate(rate_bytes_per_second) + if isinstance(burst_bytes, bool) or not isinstance(burst_bytes, int): + raise ValueError("burst bytes must be a positive integer") + if burst_bytes <= 0: + raise ValueError("burst bytes must be a positive integer") + self._capacity = burst_bytes + self._tokens = float(burst_bytes) + self._updated_at = self._clock() + self._next_token = 0 + self._reservations = {} + + @staticmethod + def _validate_rate(value): + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError("rate must be a positive finite number") from None + if not math.isfinite(value) or value <= 0: + raise ValueError("rate must be a positive finite number") + return value + + @property + def rate_bytes_per_second(self): + return self._rate + + @property + def burst_bytes(self): + return self._capacity + + def _accrue(self): + now = self._clock() + elapsed = max(0.0, now - self._updated_at) + self._updated_at = now + self._tokens = min( + float(self._capacity), self._tokens + elapsed * self._rate + ) + + def set_rate(self, rate_bytes_per_second): + self._accrue() + self._rate = self._validate_rate(rate_bytes_per_second) + + def reset(self): + self._tokens = float(self._capacity) + self._updated_at = self._clock() + self._reservations.clear() + + def reserve(self, byte_count): + if isinstance(byte_count, bool) or not isinstance(byte_count, int): + raise ValueError("reservation size must be a positive integer") + if byte_count <= 0: + raise ValueError("reservation size must be a positive integer") + self._accrue() + missing = max(0.0, byte_count - self._tokens) + self._tokens -= byte_count + self._next_token += 1 + reservation = RateReservation( + byte_count=byte_count, + delay=missing / self._rate, + token=self._next_token, + ) + self._reservations[reservation.token] = reservation.byte_count + return reservation + + def commit(self, reservation, successful_bytes=None): + reserved = self._reservations.get(reservation.token) + if reserved != reservation.byte_count: + raise ValueError("unknown or already completed reservation") + if successful_bytes is None: + successful_bytes = reserved + if ( + isinstance(successful_bytes, bool) + or not isinstance(successful_bytes, int) + or not 0 <= successful_bytes <= reserved + ): + raise ValueError("successful bytes must be within the reservation") + del self._reservations[reservation.token] + self._accrue() + refund = reserved - successful_bytes + self._tokens = min(float(self._capacity), self._tokens + refund) + return successful_bytes + + def refund(self, reservation): + return self.commit(reservation, successful_bytes=0) + + class ProtocolMimicry: """Generate sequences of PatternStep for different protocol-like behaviors.""" @@ -100,93 +254,103 @@ def __init__(self): self.enhanced = False @staticmethod - def web_browsing_session() -> List[PatternStep]: + def web_browsing_session(rng=None) -> List[PatternStep]: + rng = rng or random steps: List[PatternStep] = [] # Initial page HTML/CSS/JS fetch bursts - for _ in range(random.randint(6, 14)): - steps.append(PatternStep(size=random.randint(300, 1800), delay=random.uniform(0.005, 0.03))) + for _ in range(rng.randint(6, 14)): + steps.append(PatternStep(size=rng.randint(300, 1800), delay=rng.uniform(0.005, 0.03))) # Assets (images, fonts) - for _ in range(random.randint(8, 22)): - steps.append(PatternStep(size=random.randint(800, 4000), delay=random.uniform(0.01, 0.06))) + for _ in range(rng.randint(8, 22)): + steps.append(PatternStep(size=rng.randint(800, 4000), delay=rng.uniform(0.01, 0.06))) # Reading pause - steps.append(PatternStep(size=0, delay=random.uniform(1.2, 6.0))) + steps.append(PatternStep(size=0, delay=rng.uniform(1.2, 6.0))) # Background AJAX/pings - for _ in range(random.randint(4, 10)): - steps.append(PatternStep(size=random.randint(80, 400), delay=random.uniform(0.3, 1.5))) + for _ in range(rng.randint(4, 10)): + steps.append(PatternStep(size=rng.randint(80, 400), delay=rng.uniform(0.3, 1.5))) return steps @staticmethod - def video_streaming_session(quality: Optional[str] = None) -> List[PatternStep]: + def video_streaming_session( + quality: Optional[str] = None, rng=None + ) -> List[PatternStep]: + rng = rng or random bitrates_kbps = {"360p": 1000, "480p": 2500, "720p": 5000, "1080p": 8000} if quality not in bitrates_kbps: - quality = random.choice(list(bitrates_kbps.keys())) + quality = rng.choice(list(bitrates_kbps.keys())) bps = bitrates_kbps[quality] * 1024 // 8 # bytes/sec steps: List[PatternStep] = [] # Startup buffering (~1s) for _ in range(100): - size = int(bps / 100 * random.uniform(0.9, 1.2)) + size = int(bps / 100 * rng.uniform(0.9, 1.2)) steps.append(PatternStep(size=max(200, size), delay=0.01)) # Steady state (~10s) for _ in range(1000): - size = int(bps / 100 * random.uniform(0.95, 1.05)) + size = int(bps / 100 * rng.uniform(0.95, 1.05)) steps.append(PatternStep(size=max(100, size), delay=0.01)) # Occasional keyframe-like bursts - for _ in range(random.randint(5, 15)): - steps.append(PatternStep(size=int(bps * random.uniform(0.05, 0.15)), delay=0.02)) + for _ in range(rng.randint(5, 15)): + steps.append(PatternStep(size=int(bps * rng.uniform(0.05, 0.15)), delay=0.02)) return steps @staticmethod - def voip_call(codec: Optional[str] = None) -> List[PatternStep]: + def voip_call(codec: Optional[str] = None, rng=None) -> List[PatternStep]: + rng = rng or random codecs = {"g711": {"size": 160, "interval": 0.02}, "g729": {"size": 20, "interval": 0.02}, - "opus": {"size": random.randint(40, 120), "interval": 0.02}} + "opus": {"size": rng.randint(40, 120), "interval": 0.02}} if codec not in codecs: - codec = random.choice(list(codecs.keys())) + codec = rng.choice(list(codecs.keys())) c = codecs[codec] steps: List[PatternStep] = [] for _ in range(3000): - steps.append(PatternStep(size=max(10, int(c["size"] * random.uniform(0.9, 1.1))), - delay=c["interval"] * random.uniform(0.98, 1.02))) - if random.random() < 0.005: - steps.append(PatternStep(size=random.randint(60, 120), delay=0.0)) + steps.append(PatternStep(size=max(10, int(c["size"] * rng.uniform(0.9, 1.1))), + delay=c["interval"] * rng.uniform(0.98, 1.02))) + if rng.random() < 0.005: + steps.append(PatternStep(size=rng.randint(60, 120), delay=0.0)) return steps @staticmethod - def file_transfer_session(target_mbps: float = 10.0) -> List[PatternStep]: + def file_transfer_session( + target_mbps: float = 10.0, rng=None + ) -> List[PatternStep]: + rng = rng or random bps = mbps_to_bytes_per_second(max(0.5, target_mbps)) - mtu_pay = random.randint(1100, 1400) + mtu_pay = rng.randint(1100, 1400) interval = mtu_pay / bps steps: List[PatternStep] = [] for _ in range(2000): - steps.append(PatternStep(size=int(mtu_pay * random.uniform(0.92, 1.0)), - delay=max(0.0005, interval * random.uniform(0.9, 1.1)))) - for _ in range(random.randint(5, 15)): - steps.append(PatternStep(size=0, delay=random.uniform(0.01, 0.2))) + steps.append(PatternStep(size=int(mtu_pay * rng.uniform(0.92, 1.0)), + delay=max(0.0005, interval * rng.uniform(0.9, 1.1)))) + for _ in range(rng.randint(5, 15)): + steps.append(PatternStep(size=0, delay=rng.uniform(0.01, 0.2))) return steps @staticmethod - def gaming_session() -> List[PatternStep]: + def gaming_session(rng=None) -> List[PatternStep]: + rng = rng or random steps: List[PatternStep] = [] for _ in range(4000): - steps.append(PatternStep(size=random.randint(40, 220), delay=random.uniform(0.01, 0.05))) - if random.random() < 0.02: - steps.append(PatternStep(size=random.randint(400, 1200), delay=0.001)) + steps.append(PatternStep(size=rng.randint(40, 220), delay=rng.uniform(0.01, 0.05))) + if rng.random() < 0.02: + steps.append(PatternStep(size=rng.randint(400, 1200), delay=0.001)) return steps @staticmethod - def mixed_session() -> List[PatternStep]: + def mixed_session(rng=None) -> List[PatternStep]: + rng = rng or random choices = [ProtocolMimicry.web_browsing_session, ProtocolMimicry.video_streaming_session, ProtocolMimicry.voip_call, ProtocolMimicry.file_transfer_session, ProtocolMimicry.gaming_session] steps: List[PatternStep] = [] - for _ in range(random.randint(3, 6)): - steps.extend(random.choice(choices)()) + for _ in range(rng.randint(3, 6)): + steps.extend(rng.choice(choices)(rng=rng)) return steps @staticmethod - def for_profile(profile: TrafficProfile) -> List[PatternStep]: + def for_profile(profile: TrafficProfile, rng=None) -> List[PatternStep]: return { TrafficProfile.WEB_BROWSING: ProtocolMimicry.web_browsing_session, TrafficProfile.VIDEO_STREAMING: ProtocolMimicry.video_streaming_session, @@ -194,7 +358,7 @@ def for_profile(profile: TrafficProfile) -> List[PatternStep]: TrafficProfile.FILE_TRANSFER: ProtocolMimicry.file_transfer_session, TrafficProfile.GAMING: ProtocolMimicry.gaming_session, TrafficProfile.MIXED: ProtocolMimicry.mixed_session, - }[profile]() + }[profile](rng=rng) class DynamicObfuscator: @@ -213,18 +377,24 @@ def __init__( mtu: int = 1200, header_mode: str = "none", # none | rtp | quic fixed_buckets: Optional[Sequence[int]] = None, + rng=None, + byte_source=None, ): self.padding_strategy = padding_strategy self.timing_jitter = max(0.0, float(timing_jitter)) - self.mtu = max(256, int(mtu)) + self.mtu = int(mtu) + if self.mtu <= 0: + raise ValueError("mtu must be positive") self.header_mode = header_mode self.fixed_buckets = tuple(fixed_buckets) if fixed_buckets else (128, 256, 512, 1024, 1280, 1400) + self._rng = rng or random.Random() + self._byte_source = byte_source or os.urandom # RTP-like state - self._rtp_seq = random.randint(0, 65535) - self._rtp_ssrc = random.getrandbits(32) - self._rtp_ts_base = random.getrandbits(32) + self._rtp_seq = self._rng.randint(0, 65535) + self._rtp_ssrc = self._rng.getrandbits(32) + self._rtp_ts_base = self._rng.getrandbits(32) # QUIC-like PN - self._quic_pn = random.randint(0, 2**32 - 1) + self._quic_pn = self._rng.randint(0, 2**32 - 1) # Enhanced features if available self.enhanced = ENHANCED_AVAILABLE @@ -236,40 +406,40 @@ def __init__( self.enhanced = False def obfuscate(self, payload: bytes, profile: Optional[TrafficProfile] = None, base_delay: float = 0.0) -> Tuple[List[bytes], float]: - pkt = self._apply_header(payload, profile) - pkt = self._apply_padding(pkt, profile) + pkt = self.transform(payload, profile) fragments = self._fragment(pkt, self.mtu) - # Use base delay with small jitter for consistent throughput + # Compatibility API: preserve the caller's delay and only add bounded jitter. if base_delay > 0: - # Add small jitter (10% of base delay max) - jitter = random.gauss(0.0, min(self.timing_jitter, base_delay * 0.1)) - delay = max(0.0001, base_delay + jitter) + jitter = self._rng.gauss( + 0.0, min(self.timing_jitter, base_delay * 0.1) + ) + delay = max(0.0, base_delay + jitter) else: - # Fallback to timing model if available - if self.enhanced and hasattr(self, 'timing_model'): - delay = self.timing_model.get_delay(len(payload), network_load=0.5) - # Cap the delay to maintain throughput - delay = min(delay, 0.01) - else: - jitter = random.gauss(0.0, self.timing_jitter) - delay = max(0.0001, base_delay + jitter) + delay = 0.0 return fragments, delay + def transform(self, payload, profile=None): + """Apply optional pseudo-header and padding without packetizing.""" + if not isinstance(payload, (bytes, bytearray, memoryview)): + raise ValueError("payload must be bytes") + packet = self._apply_header(bytes(payload), profile) + return self._apply_padding(packet, profile) + def _apply_padding(self, packet: bytes, profile: Optional[TrafficProfile]) -> bytes: if self.padding_strategy == "none": return packet if self.padding_strategy == "random": max_pad = max(16, min(120, int(len(packet) * 0.07))) - pad_len = random.randint(0, max_pad) - return packet + os.urandom(pad_len) + pad_len = self._rng.randint(0, max_pad) + return packet + self._byte_source(pad_len) if self.padding_strategy == "progressive": - factor = random.uniform(0.0, 0.20) + factor = self._rng.uniform(0.0, 0.20) pad_len = int(len(packet) * factor) if pad_len <= 0: return packet - return packet + os.urandom(pad_len) + return packet + self._byte_source(pad_len) if self.padding_strategy == "fixed_buckets": target = None for b in self.fixed_buckets: @@ -281,7 +451,7 @@ def _apply_padding(self, packet: bytes, profile: Optional[TrafficProfile]) -> by pad_len = max(0, target - len(packet)) if pad_len == 0: return packet - return packet + os.urandom(pad_len) + return packet + self._byte_source(pad_len) return packet def _apply_header(self, payload: bytes, profile: Optional[TrafficProfile]) -> bytes: @@ -296,26 +466,26 @@ def _apply_header(self, payload: bytes, profile: Optional[TrafficProfile]) -> by def _rtp_like(self, payload: bytes, profile: Optional[TrafficProfile]) -> bytes: # Very rough RTP-like header (12 bytes) version, padding, extension, csrc_count = 2, 0, 0, 0 - marker = 1 if random.random() < 0.02 else 0 + marker = 1 if self._rng.random() < 0.02 else 0 payload_type = { TrafficProfile.VOIP_CALL: 111, TrafficProfile.VIDEO_STREAMING: 96, - }.get(profile, random.randint(96, 127)) + }.get(profile, self._rng.randint(96, 127)) b0 = (version << 6) | (padding << 5) | (extension << 4) | (csrc_count & 0x0F) b1 = ((marker & 0x01) << 7) | (payload_type & 0x7F) self._rtp_seq = (self._rtp_seq + 1) & 0xFFFF - ts_step = random.randint(800, 2000) + ts_step = self._rng.randint(800, 2000) self._rtp_ts_base = (self._rtp_ts_base + ts_step) & 0xFFFFFFFF header = struct.pack("!BBHII", b0, b1, self._rtp_seq, self._rtp_ts_base, self._rtp_ssrc) return header + payload def _quic_like(self, payload: bytes) -> bytes: - flags = 0xC0 | (random.randint(0, 3) << 4) - dcid_len = random.choice([8, 12, 16]) - scid_len = random.choice([0, 8, 12]) - dcid = os.urandom(dcid_len) - scid = os.urandom(scid_len) - pn_len = random.choice([1, 2, 3, 4]) + flags = 0xC0 | (self._rng.randint(0, 3) << 4) + dcid_len = self._rng.choice([8, 12, 16]) + scid_len = self._rng.choice([0, 8, 12]) + dcid = self._byte_source(dcid_len) + scid = self._byte_source(scid_len) + pn_len = self._rng.choice([1, 2, 3, 4]) self._quic_pn = (self._quic_pn + 1) & 0xFFFFFFFF pn_mask = (1 << (pn_len * 8)) - 1 pn_val = self._quic_pn & pn_mask @@ -325,7 +495,9 @@ def _quic_like(self, payload: bytes) -> bytes: @staticmethod def fragment(packet: bytes, mtu: int) -> List[bytes]: - mtu = max(256, int(mtu)) + mtu = int(mtu) + if mtu <= 0: + raise ValueError("mtu must be positive") frags: List[bytes] = [] for i in range(0, len(packet), mtu): frags.append(packet[i : i + mtu]) @@ -379,14 +551,21 @@ def detect_periodicity(packet_sizes: Sequence[int], packet_times: Sequence[float return result -def _generate_payload(size: int, entropy: float = 1.0) -> bytes: +def generate_payload( + size: int, + entropy: float = 1.0, + rng=None, + byte_source=None, +) -> bytes: + rng = rng or random.Random() + byte_source = byte_source or os.urandom size = max(0, int(size)) if size == 0: return b"" # For performance, use simple random generation by default # Enhanced entropy is expensive and should be used sparingly - if ENHANCED_AVAILABLE and size > 1000 and random.random() < 0.1: # Use enhanced only 10% of time for large packets + if ENHANCED_AVAILABLE and size > 1000 and rng.random() < 0.1: # Use enhanced only 10% of time for large packets try: enhancer = EntropyEnhancer() return enhancer.generate_realistic_encrypted_payload(size, content_type='mixed') @@ -395,20 +574,31 @@ def _generate_payload(size: int, entropy: float = 1.0) -> bytes: # Fast path for high entropy (most common case) if entropy >= 0.95: - return os.urandom(size) + return byte_source(size) # Optimized generation for lower entropy if entropy < 0.5: # Low entropy - mostly repeated bytes - base_byte = random.randint(0, 255) + base_byte = rng.randint(0, 255) data = bytearray([base_byte] * size) # Add some variation for _ in range(int(size * entropy)): - data[random.randint(0, size-1)] = random.randint(0, 255) + data[rng.randint(0, size-1)] = rng.randint(0, 255) return bytes(data) else: # Medium to high entropy - mix of random and patterns - return os.urandom(size) + return byte_source(size) + + +def profile_event_generator(profile, rng=None): + """Yield native experimental profile events without rewriting volume/gaps.""" + rng = rng or random.Random() + while True: + steps = ProtocolMimicry.for_profile(profile, rng=rng) + if not steps: + steps = [PatternStep(size=0, delay=1.0)] + for step in steps: + yield ShapeEvent(byte_count=step.size, delay=step.delay) def stream_generator( @@ -418,262 +608,33 @@ def stream_generator( max_mbps: Optional[float] = None, obfuscator: Optional[DynamicObfuscator] = None, entropy: float = 1.0, + rng=None, ) -> Iterator[Tuple[List[bytes], float]]: - """Yield (fragments, delay) continuously according to profile. - - Args: - profile: Traffic pattern profile - target_mbps: Target rate in Mbps (if min/max not specified) - min_mbps: Minimum rate in Mbps for floating rate - max_mbps: Maximum rate in Mbps for floating rate - obfuscator: Optional obfuscator instance - entropy: Payload entropy level 0.0-1.0 - """ - - # Setup rate control - if min_mbps is not None and max_mbps is not None: - # Floating rate mode - start at a random position for variety - current_mbps = random.uniform(min_mbps, max_mbps) - last_rate_update = time.time() - use_floating_rate = True - elif target_mbps is not None: - # Fixed target mode - current_mbps = target_mbps - use_floating_rate = False - else: - # No rate control - current_mbps = None - use_floating_rate = False - - # Rate tracking for proper limiting - rate_window_bytes = 0 - rate_window_start = time.time() - - # Use enhanced features if available - if ENHANCED_AVAILABLE and target_mbps and target_mbps > 10: # Only use enhanced for high rates - try: - MLResistantGenerator() - AdaptiveTimingModel(base_rtt=0.001) # Lower base RTT for higher throughput - except Exception: - pass - - steps = ProtocolMimicry.for_profile(profile) - if not steps: - steps = [PatternStep(size=1200, delay=0.001)] - - # Adjust packet sizes for better rate control - # Use consistent packet sizes for more predictable rate control - adjusted_steps = [] - for step in steps: - # Use medium-sized packets for better control - new_size = max(800, min(1400, step.size)) - # Base delay will be calculated dynamically based on current rate - new_delay = 0.001 # Minimal base delay - adjusted_steps.append(PatternStep(size=new_size, delay=new_delay)) - steps = adjusted_steps - - if obfuscator is None: - obfuscator = DynamicObfuscator() - - idx = 0 - - # For floating rate mode - pattern tracking - pattern_change_interval = random.uniform(2.0, 8.0) # Change pattern every 2-8 seconds - last_pattern_change = time.time() - - # Floating rate pattern selection - include more extreme patterns - rate_pattern_type = random.choice(['wave', 'random_walk', 'bursty', 'steady_drift', 'oscillating', 'extreme']) - rate_pattern_phase = random.uniform(0, 2 * math.pi) # Random starting phase - dwell_at_boundary = False - dwell_remaining = 0 - - while True: - # Update floating rate if enabled - if use_floating_rate and min_mbps is not None and max_mbps is not None: - now = time.time() - dt = now - last_rate_update - - if dt > 0.05: # Update rate every 50ms for smoother transitions - rate_range = max_mbps - min_mbps - rate_center = (min_mbps + max_mbps) / 2 - - # Check for pattern change - if now - last_pattern_change > pattern_change_interval: - # Switch traffic pattern - ensure we use patterns that reach boundaries - rate_pattern_type = random.choice(['full_sine', 'boundary_jumps', 'sweep', 'aggressive_random']) - pattern_change_interval = random.uniform(8.0, 20.0) - last_pattern_change = now - rate_pattern_phase = 0.0 - - # Often start at a boundary to ensure we visit them - if random.random() < 0.6: # 60% chance to start at boundary - if random.random() < 0.5: - current_mbps = max_mbps - else: - current_mbps = min_mbps - dwell_at_boundary = True - dwell_remaining = random.uniform(2.0, 5.0) - - # Handle dwelling at boundaries - if dwell_at_boundary: - dwell_remaining -= dt - if dwell_remaining <= 0: - dwell_at_boundary = False - else: - # Stay exactly at boundary - if current_mbps < rate_center: - current_mbps = min_mbps - else: - current_mbps = max_mbps - last_rate_update = now - continue - - # Apply aggressive patterns that ALWAYS use the full range - if rate_pattern_type == 'full_sine': - # Sine wave that definitely spans full range - rate_pattern_phase += dt * 0.5 # Moderate speed - wave_value = math.sin(rate_pattern_phase) - # Direct mapping ensuring we hit exact min and max - # Map [-1, 1] to [min_mbps, max_mbps] - current_mbps = min_mbps + (max_mbps - min_mbps) * (wave_value + 1.0) / 2.0 - # Force exact boundaries at extremes - if wave_value <= -0.99: - current_mbps = min_mbps - elif wave_value >= 0.99: - current_mbps = max_mbps - - elif rate_pattern_type == 'boundary_jumps': - # Frequently jump between boundaries and middle - if random.random() < 0.2: # 20% chance per update - more frequent - choice = random.random() - if choice < 0.4: - current_mbps = min_mbps # 40% chance for min - elif choice < 0.8: - current_mbps = max_mbps # 40% chance for max - else: - # 20% chance for random position in range - current_mbps = min_mbps + rate_range * random.random() - - elif rate_pattern_type == 'sweep': - # Linear sweep from min to max and back - rate_pattern_phase += dt * 0.25 # Steady sweep speed - phase_mod = rate_pattern_phase % 2.0 - if phase_mod < 1.0: - # Sweep up from min to max - ensure we hit both exactly - progress = phase_mod - if progress <= 0.02: - current_mbps = min_mbps # Start exactly at min - elif progress >= 0.98: - current_mbps = max_mbps # End exactly at max - else: - # Linear interpolation - current_mbps = min_mbps + rate_range * progress - else: - # Sweep down from max to min - ensure we hit both exactly - progress = phase_mod - 1.0 - if progress <= 0.02: - current_mbps = max_mbps # Start exactly at max - elif progress >= 0.98: - current_mbps = min_mbps # End exactly at min - else: - # Linear interpolation - current_mbps = max_mbps - rate_range * progress - - elif rate_pattern_type == 'aggressive_random': - # Aggressive random walk that favors extremes - if random.random() < 0.2: # 20% chance to jump - rand = random.random() - if rand < 0.3: - # Jump to min - current_mbps = min_mbps - elif rand < 0.6: - # Jump to max - current_mbps = max_mbps - else: - # Random position favoring extremes - if random.random() < 0.5: - # Near min - current_mbps = min_mbps + rate_range * random.uniform(0, 0.3) - else: - # Near max - current_mbps = max_mbps - rate_range * random.uniform(0, 0.3) - else: - # Small random walk - current_mbps += random.gauss(0, rate_range * 0.1) - - # Ensure we stay within bounds - current_mbps = max(min_mbps, min(max_mbps, current_mbps)) - - # Force more frequent exact boundary visits - if random.random() < 0.1: # 10% chance for guaranteed boundary hit - if random.random() < 0.5: - current_mbps = min_mbps - else: - current_mbps = max_mbps - - last_rate_update = now - - # For floating rate mode, ensure current_mbps is always within bounds - if use_floating_rate and min_mbps is not None and max_mbps is not None: - current_mbps = max(min_mbps, min(max_mbps, current_mbps)) - - step = steps[idx] - idx = (idx + 1) % len(steps) - - # Generate packet - if step.size <= 0: - fragments, _ = obfuscator.obfuscate(b"", profile=profile, base_delay=step.delay) - else: - payload = _generate_payload(step.size, entropy=entropy) - fragments, _ = obfuscator.obfuscate(payload, profile=profile, base_delay=step.delay) - - # Calculate packet size - packet_bytes = sum(len(f) for f in fragments) - - # Update rate window tracking - now = time.time() - window_elapsed = now - rate_window_start - - # Reset window every second to prevent drift - if window_elapsed > 1.0: - rate_window_bytes = 0 - rate_window_start = now - window_elapsed = 0 - - # Calculate delay to achieve target rate while strictly enforcing boundaries - if use_floating_rate and min_mbps is not None and max_mbps is not None: - # Ensure current_mbps is strictly within bounds - rate_limit_mbps = max(min_mbps, min(max_mbps, current_mbps)) - elif target_mbps: - rate_limit_mbps = target_mbps + """Compatibility iterator built on unmodified logical profile events.""" + if (min_mbps is None) != (max_mbps is None): + raise ValueError("min_mbps and max_mbps must be given together") + rng = rng or random.Random() + obfuscator = obfuscator or DynamicObfuscator(rng=rng) + events = profile_event_generator(profile, rng=rng) + rate_controlled = target_mbps is not None or min_mbps is not None + + for event in events: + if rate_controlled and event.byte_count == 0: + continue + payload = generate_payload( + event.byte_count, entropy=entropy, rng=rng + ) + fragments, _ = obfuscator.obfuscate(payload, profile=profile) + if min_mbps is not None: + rate_mbps = rng.uniform(min_mbps, max_mbps) else: - rate_limit_mbps = None - - if rate_limit_mbps and rate_limit_mbps > 0: - # Target bytes per second for desired rate (decimal Mbps) - target_bytes_per_second = mbps_to_bytes_per_second(rate_limit_mbps) - - # Simple and direct delay calculation for better rate achievement - if target_bytes_per_second > 0 and packet_bytes > 0: - # Calculate ideal delay for this packet size at target rate - delay = packet_bytes / target_bytes_per_second - - # For higher rates, reduce delay to allow bursting - if rate_limit_mbps >= 5: - delay = delay * 0.9 # Allow 10% burst for high rates - elif rate_limit_mbps >= 2: - delay = delay * 0.95 # Allow 5% burst for medium rates - - # Minimal bounds to prevent issues - delay = max(0.00001, min(0.1, delay)) - else: - delay = 0.001 + rate_mbps = target_mbps + if rate_mbps is None: + delay = event.delay else: - delay = step.delay if hasattr(step, 'delay') else 0.001 - - # Update window counter - rate_window_bytes += packet_bytes - + delay = sum(len(fragment) for fragment in fragments) / ( + mbps_to_bytes_per_second(rate_mbps) + ) yield fragments, delay @@ -699,12 +660,14 @@ def parse_profile(profile: Union[str, TrafficProfile, None]) -> TrafficProfile: return TrafficProfile.MIXED -def build_obfuscator(cfg: ObfuscationConfig) -> DynamicObfuscator: +def build_obfuscator(cfg: ObfuscationConfig, rng=None, byte_source=None) -> DynamicObfuscator: return DynamicObfuscator( padding_strategy=cfg.padding_strategy, timing_jitter=cfg.timing_jitter, mtu=cfg.mtu, header_mode=cfg.header_mode, + rng=rng, + byte_source=byte_source, ) diff --git a/traffic-masking/systemd/README.md b/traffic-masking/systemd/README.md index e9fa4b9..6e77b4b 100644 --- a/traffic-masking/systemd/README.md +++ b/traffic-masking/systemd/README.md @@ -45,9 +45,9 @@ sudo sed -i 's|/usr/bin/python3|/opt/traffic-masking/venv/bin/python|g' \ ### Server Configuration -The server service is configured with maximum security features: -- Floating rate: 3-10 Mbps -- Advanced mode with ML resistance +The server service is configured with authenticated experimental profile shaping: +- Native mixed-profile offered load capped at 10 Mbps +- Profile mode (the cap does not increase native offered load) - RTP headers and random padding - Maximum entropy (1.0) @@ -61,7 +61,7 @@ Example override to change rate: [Service] ExecStart= ExecStart=/opt/traffic-masking/venv/bin/python /opt/traffic-masking/traffic_masking_server.py \ - --min-mbps 1 --max-mbps 5 --advanced --profile video \ + --shape-mode profile --max-mbps 5 --profile video \ --psk-file /etc/traffic-masking/control.psk ``` diff --git a/traffic-masking/systemd/traffic-masking-server.service b/traffic-masking/systemd/traffic-masking-server.service index 54057cb..fb1fecc 100644 --- a/traffic-masking/systemd/traffic-masking-server.service +++ b/traffic-masking/systemd/traffic-masking-server.service @@ -15,13 +15,12 @@ WorkingDirectory=/opt/traffic-masking # Environment Environment="PYTHONUNBUFFERED=1" -# Maximum configuration with floating rate +# Experimental native profile with an explicit ceiling ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py \ --host 0.0.0.0 \ --port 8888 \ - --min-mbps 3 \ --max-mbps 10 \ - --advanced \ + --shape-mode profile \ --profile mixed \ --header rtp \ --padding random \ diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index a3681ef..100d06d 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -103,6 +103,28 @@ def _run(script, *args): ("--insecure-diagnostic", "--stats-interval", "inf"), "positive finite number", ), + ( + SERVER, + ( + "--insecure-diagnostic", "--shape-mode", "rate", + "--profile", "voip", + ), + "profile is not valid in rate shape mode", + ), + ( + SERVER, + ("--insecure-diagnostic", "--shape-mode", "profile"), + "requires --profile", + ), + ( + SERVER, + ( + "--insecure-diagnostic", "--shape-mode", "profile", + "--profile", "voip", "--min-mbps", "0.5", + "--max-mbps", "1", + ), + "min-mbps is not valid", + ), ( CLIENT, ( @@ -150,3 +172,22 @@ def test_server_rejects_limits_below_per_client_rate(): def test_constructors_reject_non_byte_psks(factory): with pytest.raises(ValueError, match="psk must be bytes"): factory() + + +def test_profile_mode_has_native_load_and_optional_cap(): + uncapped = MaskingTrafficServer( + shape_mode="profile", profile="voip", psk=TEST_PSK + ) + capped = MaskingTrafficServer( + shape_mode="profile", profile="voip", max_mbps=1, psk=TEST_PSK + ) + assert uncapped.target_mbps is None + assert uncapped.max_mbps is None + assert capped.configured_max_mbps == 1 + + +def test_advanced_warns_and_translates_to_profile_mode(): + with pytest.warns(FutureWarning, match="shape-mode profile"): + server = MaskingTrafficServer(advanced=True, psk=TEST_PSK) + assert server.shape_mode == "profile" + assert server.profile.value == "mixed" diff --git a/traffic-masking/test_core.py b/traffic-masking/test_core.py index 213ce20..e16a0c9 100644 --- a/traffic-masking/test_core.py +++ b/traffic-masking/test_core.py @@ -3,6 +3,7 @@ """Fast in-process smoke tests for the core library (characterization baseline).""" +import random import socket import pytest @@ -25,26 +26,33 @@ def test_parse_profile_known_and_fallback(): @pytest.mark.parametrize("profile", list(TrafficProfile)) def test_for_profile_is_nonempty(profile): - steps = ProtocolMimicry.for_profile(profile) + steps = ProtocolMimicry.for_profile(profile, rng=random.Random(profile.value)) assert len(steps) > 0 def test_obfuscator_produces_fragments(): - obf = DynamicObfuscator() + obf = DynamicObfuscator(rng=random.Random(1)) fragments, delay = obf.obfuscate(b"test packet data") assert len(fragments) > 0 assert delay >= 0 def test_stream_generator_fixed_rate_yields(): - gen = stream_generator(TrafficProfile.MIXED, target_mbps=1.0) + gen = stream_generator( + TrafficProfile.MIXED, target_mbps=1.0, rng=random.Random(2) + ) fragments, delay = next(gen) assert len(fragments) > 0 assert delay > 0 def test_stream_generator_floating_rate_yields(): - gen = stream_generator(TrafficProfile.MIXED, min_mbps=1.0, max_mbps=5.0) + gen = stream_generator( + TrafficProfile.MIXED, + min_mbps=1.0, + max_mbps=5.0, + rng=random.Random(3), + ) fragments, delay = next(gen) assert len(fragments) > 0 assert delay > 0 diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index 1b1193d..9c5447b 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -30,8 +30,8 @@ def _server_args(port, psk_file, lo=2, hi=4): return [ "--host", "127.0.0.1", "--port", str(port), - "--min-mbps", str(lo), "--max-mbps", str(hi), - "--advanced", "--profile", "mixed", "--stats-interval", "1", + "--shape-mode", "profile", "--max-mbps", str(hi), + "--profile", "mixed", "--stats-interval", "1", "--psk-file", str(psk_file), ] @@ -140,7 +140,12 @@ def test_floating_rate_stays_within_bounds(spawn, start_server, psk_file): """ lo, hi = 2.0, 6.0 server, port = start_server( - lambda selected_port: _server_args(selected_port, psk_file, lo, hi), + lambda selected_port: [ + "--host", "127.0.0.1", "--port", str(selected_port), + "--shape-mode", "rate", "--min-mbps", str(lo), + "--max-mbps", str(hi), "--stats-interval", "1", + "--psk-file", str(psk_file), + ], "server", ) diff --git a/traffic-masking/test_rate.py b/traffic-masking/test_rate.py index 9508cab..a0ae62d 100644 --- a/traffic-masking/test_rate.py +++ b/traffic-masking/test_rate.py @@ -1,13 +1,13 @@ # Copyright © 2026 kogeler # SPDX-License-Identifier: Apache-2.0 -"""Rate correctness: decimal-Mbps conversion and pacing budget accounting.""" +"""Rate correctness: decimal units and reservation-based pacing.""" import masking_lib import pytest from conftest import TEST_PSK -from masking_lib import ProtocolMimicry, mbps_to_bytes_per_second -from traffic_masking_server import MaskingTrafficServer, _budget_bytes, _RateBudget +from masking_lib import ProtocolMimicry, RateLimiter, mbps_to_bytes_per_second +from traffic_masking_server import MaskingTrafficServer class FakeClock: @@ -57,50 +57,40 @@ def deterministic_uniform(low, high): assert first_step.size / first_step.delay == pytest.approx(125_000) -def test_budget_zero_for_nonpositive_inputs(): - assert _budget_bytes(0, 1.0) == 0 - assert _budget_bytes(-5, 1.0) == 0 - assert _budget_bytes(125_000, 0.0) == 0 - assert _budget_bytes(125_000, -1.0) == 0 - - -def test_budget_caps_idle_gap_to_one_tick(): - # A long idle gap must not turn into accumulated credit: at most one - # scheduling tick of bytes is granted no matter how much time passed. - rate = 125_000 # 1 Mbps - assert _budget_bytes(rate, 60.0) == _budget_bytes(rate, 0.1) - assert _budget_bytes(rate, 3600.0) == _budget_bytes(rate, 0.1) - - -def test_budget_tracks_configured_rate_over_fake_clock_window(): - # Simulate the pacing loop over a fake one-second window of 10 ms ticks - # at a constant commanded rate; the granted budget must stay within ±15% - # of the configured decimal rate (exact modulo integer truncation). +def test_rate_limiter_tracks_target_without_exceeding_short_or_long_cap(): clock = FakeClock() - budget = _RateBudget(clock=clock) target = mbps_to_bytes_per_second(1) - submitted = 0 + limiter = RateLimiter(target, burst_bytes=1200, clock=clock) + sent = 0 - for _ in range(100): - clock.advance(0.01) - allowed = budget.accrue(target) - submitted += allowed - budget.consume(allowed) + for _ in range(1200): + reservation = limiter.reserve(1200) + clock.advance(reservation.delay) + sent += limiter.commit(reservation) + assert sent <= target * clock.now + limiter.burst_bytes + 1 - assert abs(submitted - target) <= target * 0.15 + sustained = (sent - limiter.burst_bytes) / clock.now + assert sustained == pytest.approx(target, rel=0.01) -def test_rate_budget_caps_elapsed_and_resets_idle_credit(): +def test_rate_limiter_initial_burst_and_failed_send_refund_are_bounded(): clock = FakeClock() - budget = _RateBudget(clock=clock) target = mbps_to_bytes_per_second(1) + limiter = RateLimiter(target, burst_bytes=1200, clock=clock) + + first = limiter.reserve(1200) + assert first.delay == 0 + limiter.commit(first) + + failed = limiter.reserve(1200) + assert failed.delay == pytest.approx(1200 / target) + clock.advance(failed.delay) + limiter.refund(failed) - clock.advance(60) - assert budget.accrue(target) == _budget_bytes(target, 0.1) - budget.reset() - assert budget.available == 0 + retry = limiter.reserve(1200) + assert retry.delay == 0 + limiter.commit(retry, successful_bytes=600) - clock.advance(0.01) - assert budget.accrue(target) == pytest.approx( - _budget_bytes(target, 0.01), abs=1 - ) + after_partial = limiter.reserve(600) + assert after_partial.delay == 0 + limiter.commit(after_partial) diff --git a/traffic-masking/test_shaping.py b/traffic-masking/test_shaping.py new file mode 100644 index 0000000..dcfc285 --- /dev/null +++ b/traffic-masking/test_shaping.py @@ -0,0 +1,148 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Explicit shaping, packetization, and profile-mode contracts.""" + +import random + +import pytest + +from control_protocol import ( + CLIENT_TO_SERVER, + FRAME_OVERHEAD, + MessageType, + derive_session_key, + encode_frame, +) +from masking_lib import ( + Packetizer, + PatternStep, + ProtocolMimicry, + ShapeEvent, + TrafficProfile, + profile_event_generator, +) +from traffic_masking_client import AdaptiveTrafficClient + + +def test_packetizer_preserves_large_event_and_final_datagram_ceiling(): + packetizer = Packetizer(datagram_ceiling=1200, framing_overhead=FRAME_OVERHEAD) + payload = bytes(range(256)) * 40 + + fragments = packetizer.packetize(payload) + + assert b"".join(fragments) == payload + assert sum(map(len, fragments)) == len(payload) + assert len(fragments) > 1 + assert all(len(fragment) + FRAME_OVERHEAD <= 1200 for fragment in fragments) + + +def test_shape_event_and_packetizer_reject_invalid_dimensions(): + with pytest.raises(ValueError, match="byte_count"): + ShapeEvent(-1) + with pytest.raises(ValueError, match="delay"): + ShapeEvent(1, float("inf")) + with pytest.raises(ValueError, match="no payload"): + Packetizer(82, framing_overhead=82) + + +def test_profile_event_generator_preserves_native_steps(monkeypatch): + steps = [ + ShapeEvent(8_000, 0.25), + ShapeEvent(0, 1.5), + ShapeEvent(120, 0.02), + ] + monkeypatch.setattr( + ProtocolMimicry, + "for_profile", + lambda profile, rng=None: [ + PatternStep(size=event.byte_count, delay=event.delay) + for event in steps + ], + ) + events = profile_event_generator( + TrafficProfile.VIDEO_STREAMING, rng=random.Random(7) + ) + + observed = [next(events) for _ in steps] + + assert observed == steps + + +def test_voip_native_rate_remains_below_one_mbps_cap(): + steps = ProtocolMimicry.voip_call( + codec="g711", rng=random.Random(1234) + ) + total_bytes = sum(step.size for step in steps) + total_seconds = sum(step.delay for step in steps) + native_mbps = total_bytes * 8 / total_seconds / 1_000_000 + + assert 0.05 <= native_mbps <= 0.08 + assert native_mbps < 1.0 + + +def test_packetized_payloads_fit_after_authenticated_framing(): + packetizer = Packetizer(1200, framing_overhead=FRAME_OVERHEAD) + payload = b"v" * 25_000 + + datagrams = [ + encode_frame( + MessageType.DATA, + b"c" * 16, + b"s" * 16, + sequence, + b"k" * 32, + payload=fragment, + ) + for sequence, fragment in enumerate(packetizer.packetize(payload), 1) + ] + + assert all(len(datagram) <= 1200 for datagram in datagrams) + assert sum(len(datagram) - FRAME_OVERHEAD for datagram in datagrams) == len( + payload + ) + + +def test_large_video_event_is_not_truncated_to_one_datagram(): + event = ProtocolMimicry.video_streaming_session( + quality="1080p", rng=random.Random(8) + )[0] + packetizer = Packetizer(1200, framing_overhead=FRAME_OVERHEAD) + payload = b"v" * event.size + + fragments = packetizer.packetize(payload) + + assert event.size > packetizer.payload_ceiling + assert len(fragments) > 1 + assert sum(map(len, fragments)) == event.size + + +class RecordingSocket: + def __init__(self): + self.sent = [] + + def sendto(self, datagram, address): + self.sent.append((bytes(datagram), address)) + return len(datagram) + + +def test_client_packetizes_large_uplink_before_session_framing(): + key = b"k" * 32 + client = AdaptiveTrafficClient("server.example", 8888, psk=key, mtu=1200) + client.socket = RecordingSocket() + client.server_addr = ("192.0.2.1", 8888) + client.client_nonce = b"c" * 16 + client.session_nonce = b"s" * 16 + client.session_send_key = derive_session_key( + key, client.client_nonce, client.session_nonce, CLIENT_TO_SERVER + ) + client.handshake_accepted = True + payload = b"u" * 9_000 + + client.send_packet(payload) + + assert len(client.socket.sent) > 1 + assert all(len(datagram) <= client.mtu for datagram, _ in client.socket.sent) + assert sum( + len(datagram) - FRAME_OVERHEAD for datagram, _ in client.socket.sent + ) == len(payload) diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index 7ca2128..6b6664a 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -42,6 +42,7 @@ ) from masking_lib import ( ObfuscationConfig, + Packetizer, build_obfuscator, init_udp_socket, mbps_to_bytes_per_second, @@ -157,7 +158,8 @@ def __init__( self.insecure_diagnostic = bool(insecure_diagnostic) self.keepalive_jitter = keepalive_jitter self.mtu = mtu - self.data_payload_ceiling = mtu - FRAME_OVERHEAD + self.packetizer = Packetizer(mtu, FRAME_OVERHEAD) + self.data_payload_ceiling = self.packetizer.payload_ceiling self._send_lock = threading.Lock() self.client_nonce = ZERO_NONCE self.session_nonce = ZERO_NONCE @@ -421,7 +423,9 @@ def connect(self): print(f"[*] Control authentication: {auth_mode}", flush=True) # Initialize obfuscator in advanced mode if self.advanced: - self.obfuscator = build_obfuscator(self.obf_cfg) + self.obfuscator = build_obfuscator( + self.obf_cfg, rng=self._rng, byte_source=self._byte_source + ) print( f"[*] Advanced client mode: uplink_profile={self.uplink_profile.value}, header={self.obf_cfg.header_mode}, padding={self.obf_cfg.padding_strategy}, mtu={self.obf_cfg.mtu}, entropy={self.obf_cfg.entropy}", flush=True, @@ -468,16 +472,11 @@ def send_packet(self, packet): """Send packet to the server""" try: if self.advanced and self.obfuscator is not None: - fragments, delay = self.obfuscator.obfuscate( - packet, profile=self.uplink_profile, base_delay=0.0 + packet = self.obfuscator.transform( + packet, profile=self.uplink_profile ) - if delay > 0: - time.sleep(delay) - - for fragment in fragments: - self._send_session_message(MessageType.DATA, fragment) - else: - self._send_session_message(MessageType.DATA, packet) + for fragment in self.packetizer.packetize(packet): + self._send_session_message(MessageType.DATA, fragment) except Exception as e: print(f"[!] Send error: {e}", flush=True) diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index b55f80f..ca2d90f 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -19,9 +19,9 @@ import struct import threading import time +import warnings from collections import OrderedDict, deque -import numpy as np from control_protocol import ( CLIENT_TO_SERVER, CONTROL_PADDING_MAX, @@ -47,60 +47,16 @@ ) from masking_lib import ( DynamicObfuscator, + Packetizer, + RateLimiter, + ShapeEvent, TrafficProfile, + generate_payload, mbps_to_bytes_per_second, - stream_generator, + profile_event_generator, ) -_MAX_PACING_TICK_SECONDS = 0.1 - - -def _budget_bytes( - rate_bytes_per_second, elapsed, max_tick=_MAX_PACING_TICK_SECONDS -): - """Bytes allowed to send over ``elapsed`` seconds at the given byte rate. - - ``elapsed`` is clamped to ``max_tick`` so an idle gap (no clients) cannot - accumulate a burst of credit that floods the next client to connect. - """ - if rate_bytes_per_second <= 0 or elapsed <= 0: - return 0 - return int(rate_bytes_per_second * min(elapsed, max_tick)) - - -class _RateBudget: - """Monotonic byte-credit accumulator for the legacy pacing loop.""" - - def __init__(self, clock=None, max_tick=_MAX_PACING_TICK_SECONDS): - if max_tick <= 0: - raise ValueError("max_tick must be positive") - self._clock = clock or time.monotonic - self._max_tick = max_tick - self._last_time = self._clock() - self._credit = 0.0 - - @property - def available(self): - return max(0, int(self._credit)) - - def reset(self): - self._last_time = self._clock() - self._credit = 0.0 - - def accrue(self, rate_bytes_per_second): - now = self._clock() - elapsed = max(0.0, now - self._last_time) - self._last_time = now - if rate_bytes_per_second > 0: - self._credit += rate_bytes_per_second * min(elapsed, self._max_tick) - return self.available - - def consume(self, byte_count): - if byte_count > 0: - self._credit -= byte_count - - def _positive_finite_float(value, name): try: value = float(value) @@ -135,72 +91,6 @@ def _positive_int(value, name): return parsed -class TrafficPattern: - """Generator of diverse traffic rate patterns (CBR, bursts, waves, random-walk, media-like)""" - - def __init__(self): - self.patterns = [ - self.constant_bitrate, - self.burst_pattern, - self.wave_pattern, - self.random_walk, - self.media_like_pattern, - ] - self.current_pattern = random.choice(self.patterns) - self.pattern_duration = random.uniform(5, 30) # seconds - self.pattern_start = time.time() - - def should_switch_pattern(self): - """Check if pattern switch is needed""" - return time.time() - self.pattern_start > self.pattern_duration - - def switch_pattern(self): - """Switch to a new pattern""" - self.current_pattern = random.choice(self.patterns) - self.pattern_duration = random.uniform(5, 30) - self.pattern_start = time.time() - - def constant_bitrate(self, base_rate): - """Constant bitrate with small fluctuations""" - return base_rate * random.uniform(0.95, 1.05) - - def burst_pattern(self, base_rate): - """Traffic bursts""" - if random.random() < 0.1: # 10% chance of burst - return base_rate * random.uniform(2, 4) - return base_rate * random.uniform(0.5, 0.8) - - def wave_pattern(self, base_rate): - """Wave-like pattern""" - t = time.time() - wave = np.sin(t / 5) * 0.5 + 1 # Sine wave with period ~31 sec - return base_rate * wave * random.uniform(0.9, 1.1) - - def random_walk(self, base_rate): - """Random walk""" - if not hasattr(self, "walk_value"): - self.walk_value = base_rate - change = random.uniform(-0.1, 0.1) * base_rate - self.walk_value = max( - base_rate * 0.3, min(base_rate * 2, self.walk_value + change) - ) - return self.walk_value - - def media_like_pattern(self, base_rate): - """Media-like stream (video/audio)""" - # Base flow + periodic key frames - base = base_rate * 0.7 - if random.random() < 0.05: # 5% - "key frames" - return base + base_rate * random.uniform(0.5, 1.5) - return base + random.uniform(-0.1, 0.1) * base_rate - - def get_current_rate(self, base_rate): - """Get current bitrate""" - if self.should_switch_pattern(): - self.switch_pattern() - return self.current_pattern(base_rate) - - class PacketGenerator: """Packet generator with variable sizes and pseudo-random payload characteristics""" @@ -252,11 +142,12 @@ def __init__( self, host="0.0.0.0", port=8888, - target_mbps=5, + target_mbps=None, min_mbps=None, max_mbps=None, advanced=False, - profile="mixed", + profile=None, + shape_mode="rate", header="none", padding="random", mtu=1200, @@ -272,21 +163,61 @@ def __init__( rng=None, byte_source=None, cookie_secret=None, + monotonic_clock=None, + sleep=None, ): - # Validate configuration up front; fail fast on invalid rates/ranges. - floating = min_mbps is not None and max_mbps is not None - if (min_mbps is None) != (max_mbps is None): - raise ValueError("min-mbps and max-mbps must be given together") - if floating: - min_mbps = _positive_finite_float(min_mbps, "min-mbps") - max_mbps = _positive_finite_float(max_mbps, "max-mbps") - if min_mbps >= max_mbps: - raise ValueError("min-mbps must be less than max-mbps") - target_mbps = None - else: - target_mbps = _positive_finite_float( - target_mbps, "target rate (--mbps)" + # Validate the offered-load contract before constructing generators. + if shape_mode not in ("rate", "profile"): + raise ValueError("shape-mode must be 'rate' or 'profile'") + if advanced: + warnings.warn( + "--advanced is deprecated; use --shape-mode profile", + FutureWarning, + stacklevel=2, ) + shape_mode = "profile" + profile = profile or "mixed" + if min_mbps is not None: + if max_mbps is None: + raise ValueError("min-mbps and max-mbps must be given together") + warnings.warn( + "--advanced translates the old min/max range to a profile cap", + FutureWarning, + stacklevel=2, + ) + min_mbps = None + if target_mbps is not None: + if max_mbps is None: + max_mbps = target_mbps + target_mbps = None + + floating = False + if shape_mode == "rate": + if profile is not None: + raise ValueError("profile is not valid in rate shape mode") + if (min_mbps is None) != (max_mbps is None): + raise ValueError("min-mbps and max-mbps must be given together") + floating = min_mbps is not None + if floating: + min_mbps = _positive_finite_float(min_mbps, "min-mbps") + max_mbps = _positive_finite_float(max_mbps, "max-mbps") + if min_mbps >= max_mbps: + raise ValueError("min-mbps must be less than max-mbps") + target_mbps = None + else: + target_mbps = _positive_finite_float( + 5 if target_mbps is None else target_mbps, + "target rate (--mbps)", + ) + else: + if profile is None: + raise ValueError("profile shape mode requires --profile") + if min_mbps is not None: + raise ValueError("min-mbps is not valid in profile shape mode") + if target_mbps is not None: + raise ValueError("mbps is not valid in profile shape mode") + if max_mbps is not None: + max_mbps = _positive_finite_float(max_mbps, "max-mbps") mtu = _positive_int(mtu, "mtu") if mtu > MAX_DATAGRAM_SIZE: raise ValueError(f"mtu must not exceed {MAX_DATAGRAM_SIZE}") @@ -295,10 +226,10 @@ def __init__( f"mtu must be at least {MIN_CONTROL_MTU} bytes " "for authenticated control framing" ) - if advanced and mtu - FRAME_OVERHEAD < 256: + if shape_mode == "profile" and mtu - FRAME_OVERHEAD < 256: raise ValueError( f"mtu must be at least {FRAME_OVERHEAD + 256} bytes " - "in advanced mode" + "in profile shape mode" ) entropy = _unit_interval_float(entropy, "entropy") stats_interval = _positive_finite_float( @@ -312,7 +243,11 @@ def __init__( max_handshakes_per_second, "max-handshakes-per-second" ) cookie_ttl = _positive_int(cookie_ttl, "cookie-ttl") - configured_max_mbps = max_mbps if floating else target_mbps + configured_max_mbps = ( + max_mbps + if max_mbps is not None + else (target_mbps if shape_mode == "rate" else max_total_mbps) + ) if configured_max_mbps > max_total_mbps: raise ValueError( "max-total-mbps must be at least the configured per-client maximum" @@ -334,6 +269,8 @@ def __init__( ) self._clock = clock or time.time + self._monotonic_clock = monotonic_clock or time.monotonic + self._sleep = sleep or time.sleep self._rng = rng or random.Random() self._byte_source = byte_source or os.urandom self.base_key = psk if psk is not None else INSECURE_DIAGNOSTIC_KEY @@ -352,16 +289,23 @@ def __init__( self.target_mbps = target_mbps self.min_mbps = min_mbps self.max_mbps = max_mbps - # Rate is decimal Mbps of application bytes; store the target in bytes/s. - if floating: - self.target_bytes_per_second = mbps_to_bytes_per_second((min_mbps + max_mbps) / 2) - else: - self.target_bytes_per_second = mbps_to_bytes_per_second(target_mbps) + self.shape_mode = shape_mode + self.current_rate_mbps = ( + (min_mbps + max_mbps) / 2 + if floating + else target_mbps + ) + self._last_rate_update = self._monotonic_clock() + self.target_bytes_per_second = ( + mbps_to_bytes_per_second(self.current_rate_mbps) + if self.current_rate_mbps is not None + else None + ) self.socket = None self.clients = {} # Only authenticated/validated sessions. self.running = False - self.pattern_gen = TrafficPattern() - self.data_payload_ceiling = mtu - FRAME_OVERHEAD + self.packetizer = Packetizer(mtu, FRAME_OVERHEAD) + self.data_payload_ceiling = self.packetizer.payload_ceiling self.packet_gen = PacketGenerator( max_size=min(1400, self.data_payload_ceiling), rng=self._rng, @@ -370,17 +314,16 @@ def __init__( self.stats = {"bytes_sent": 0, "packets_sent": 0, "start_time": time.time()} self.last_stats = {"bytes_sent": 0, "packets_sent": 0, "time": time.time()} self.stats_interval = stats_interval - # Advanced masking options - self.advanced = bool(advanced) - # Normalize profile to TrafficProfile - try: - self.profile = ( - TrafficProfile(profile) - if isinstance(profile, str) - else (profile or TrafficProfile.MIXED) - ) - except Exception: - self.profile = TrafficProfile.MIXED + self.advanced = shape_mode == "profile" # Compatibility attribute. + if shape_mode == "profile": + try: + self.profile = ( + TrafficProfile(profile) if isinstance(profile, str) else profile + ) + except (TypeError, ValueError): + raise ValueError(f"unknown traffic profile: {profile}") from None + else: + self.profile = None self.header_mode = header self.padding_strategy = padding self.mtu = mtu @@ -414,33 +357,36 @@ def start(self): print( f"[*] Traffic masking server started on {self.host}:{self.port}", flush=True ) - if self.min_mbps is not None and self.max_mbps is not None: + if self.shape_mode == "profile": + print( + f"[*] Experimental profile shaping: {self.profile.value}" + + ( + f" (cap {self.max_mbps} Mbps)" + if self.max_mbps is not None + else " (native offered load)" + ), + flush=True, + ) + elif self.min_mbps is not None and self.max_mbps is not None: print( f"[*] Floating throughput: {self.min_mbps}-{self.max_mbps} Mbps", flush=True, ) else: print(f"[*] Target throughput: {self.target_mbps} Mbps", flush=True) - if self.advanced: - # Initialize obfuscator and generator + if self.shape_mode == "profile": self.obfuscator = DynamicObfuscator( padding_strategy=self.padding_strategy, timing_jitter=0.002, mtu=self.data_payload_ceiling, header_mode=self.header_mode, + rng=self._rng, + byte_source=self._byte_source, ) - self.generator = stream_generator( - self.profile, - target_mbps=self.target_mbps - if (self.min_mbps is None or self.max_mbps is None) - else None, - min_mbps=self.min_mbps, - max_mbps=self.max_mbps, - obfuscator=self.obfuscator, - entropy=self.entropy, - ) + self.generator = profile_event_generator(self.profile, rng=self._rng) print( - f"[*] Advanced mode enabled: profile={self.profile.value}, header={self.header_mode}, padding={self.padding_strategy}, mtu={self.mtu}, entropy={self.entropy}", + f"[*] Profile transform: header={self.header_mode}, " + f"padding={self.padding_strategy}, mtu={self.mtu}", flush=True, ) auth_mode = "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" @@ -600,6 +546,11 @@ def _handle_auth(self, frame, addr, entry, now): self._accepted_auth[replay_key] = { "expires": now + self.cookie_ttl } + limiter_mbps = ( + self.current_rate_mbps + if self.shape_mode == "rate" + else self.max_mbps + ) self.clients[addr] = { "last_seen": now, "bytes_received": 0, @@ -610,6 +561,15 @@ def _handle_auth(self, frame, addr, entry, now): "send_key": send_key, "receive_sequence": frame.sequence, "send_sequence": 0, + "rate_limiter": ( + RateLimiter( + mbps_to_bytes_per_second(limiter_mbps), + burst_bytes=self.mtu, + clock=self._monotonic_clock, + ) + if limiter_mbps is not None + else None + ), } print(f"[+] New client connected: {addr}", flush=True) return True @@ -684,122 +644,74 @@ def _frame_data_for_client(self, client, payload): ) def send_loop(self): - """Send cover traffic to clients""" - # Pacing uses the monotonic clock: wall-clock steps (NTP) must not - # produce negative or inflated byte budgets. - rate_budget = _RateBudget() - - # Rate control for advanced mode - rate_window_bytes = 0 - rate_window_start = time.time() - + """Generate logical demand, packetize it, and pace framed datagrams.""" while self.running: if not self.clients: - time.sleep(0.1) - # No clients: reset pacing so idle time is not billed as a burst - # to the next client that connects. - rate_budget.reset() + self._sleep(0.1) continue - # Advanced generator-driven mode with proper rate limiting - if getattr(self, "advanced", False) and self.generator is not None: - try: - frags, base_delay = next(self.generator) - except StopIteration: - # Recreate generator if it ever stops - self.generator = stream_generator( - self.profile, - target_mbps=self.target_mbps - if not (self.min_mbps and self.max_mbps) - else None, - min_mbps=self.min_mbps, - max_mbps=self.max_mbps, - obfuscator=self.obfuscator, - entropy=self.entropy, - ) - frags, base_delay = next(self.generator) - - # Send fragments and track bytes - packet_bytes = 0 - for frag in frags: + event = self._next_shape_event() + if event.byte_count: + payload = self._make_event_payload(event) + for fragment in self.packetizer.packetize(payload): for addr, client in list(self.clients.items()): - try: - framed = self._frame_data_for_client(client, frag) - sent = self.socket.sendto(framed, addr) - if sent != len(framed): - continue - self.stats["bytes_sent"] += sent - self.stats["packets_sent"] += 1 - packet_bytes += sent - except Exception as e: - print(f"[!] Send error to client {addr}: {e}", flush=True) - - # Update rate window - rate_window_bytes += packet_bytes - now = time.time() - window_elapsed = now - rate_window_start - - # Reset window every second - if window_elapsed > 1.0: - rate_window_bytes = 0 - rate_window_start = now - window_elapsed = 0 - - # Use the delay from generator which already implements rate limiting - time.sleep(base_delay) + self._send_fragment(addr, client, fragment) + if event.delay: + self._sleep(event.delay) - continue + def _next_shape_event(self): + if self.shape_mode == "profile": + return next(self.generator) - # Legacy accumulator mode (default) - # Current target rate in bytes/s (the pattern scales the byte budget). - current_rate_bps = self.pattern_gen.get_current_rate( - self.target_bytes_per_second + now = self._monotonic_clock() + if ( + self.min_mbps is not None + and now - self._last_rate_update >= 1.0 + ): + self.current_rate_mbps = self._rng.uniform( + self.min_mbps, self.max_mbps ) - - # Bytes allowed for this interval; elapsed time is capped to one tick. - bytes_available = rate_budget.accrue(current_rate_bps) - - # Send packets in batches for efficiency - packets_sent_this_round = 0 - while ( - bytes_available >= FRAME_OVERHEAD + 28 - and self.clients - and packets_sent_this_round < 50 - ): - target_frame_size = min( - bytes_available, self.mtu, self._rng.randint(1000, 1400) - ) - payload_size = target_frame_size - FRAME_OVERHEAD - packet = self.packet_gen.generate_packet(payload_size) - framed_size = FRAME_OVERHEAD + len(packet) - - # Send to all active clients - for addr, client in list(self.clients.items()): - try: - framed = self._frame_data_for_client(client, packet) - sent = self.socket.sendto(framed, addr) - if sent != len(framed): - continue - self.stats["bytes_sent"] += sent - self.stats["packets_sent"] += 1 - except Exception as e: - print(f"[!] Send error to client {addr}: {e}") - - rate_budget.consume(framed_size) - bytes_available = rate_budget.available - packets_sent_this_round += 1 - - # Minimal sleep between packets in batch - if packets_sent_this_round % 10 == 0: - time.sleep(0.0001) - - # Adaptive pacing based on accumulator - if rate_budget.available > current_rate_bps * 0.1: - # Behind schedule, don't sleep - pass + self._last_rate_update = now + new_rate = mbps_to_bytes_per_second(self.current_rate_mbps) + self.target_bytes_per_second = new_rate + for client in self.clients.values(): + client["rate_limiter"].set_rate(new_rate) + return ShapeEvent(byte_count=self.data_payload_ceiling) + + def _make_event_payload(self, event): + if self.shape_mode == "rate": + return self.packet_gen.generate_packet(event.byte_count) + payload = bytes( + generate_payload( + event.byte_count, + entropy=self.entropy, + rng=self._rng, + byte_source=self._byte_source, + ) + ) + if len(payload) != event.byte_count: + raise ValueError("byte source returned the wrong event payload length") + return self.obfuscator.transform(payload, profile=self.profile) + + def _send_fragment(self, addr, client, fragment): + framed = self._frame_data_for_client(client, fragment) + limiter = client["rate_limiter"] + reservation = limiter.reserve(len(framed)) if limiter else None + if reservation and reservation.delay: + self._sleep(reservation.delay) + sent = 0 + try: + sent = self.socket.sendto(framed, addr) + if sent == len(framed): + self.stats["bytes_sent"] += sent + self.stats["packets_sent"] += 1 else: - # On schedule, small sleep - time.sleep(0.0005) + sent = max(0, min(sent, len(framed))) + except OSError as exc: + print(f"[!] Send error to client {addr}: {exc}", flush=True) + finally: + if reservation: + limiter.commit(reservation, successful_bytes=sent) def cleanup_loop(self): """Remove inactive clients""" @@ -834,9 +746,9 @@ def stats_loop(self): pps = packets_delta / time_delta pattern_desc = ( - self.pattern_gen.current_pattern.__name__ - if not getattr(self, "advanced", False) - else f"advanced:{getattr(self, 'profile', None).value}/{getattr(self, 'obfuscator', None).header_mode}" + f"rate:{self.current_rate_mbps:.2f}Mbps" + if self.shape_mode == "rate" + else f"experimental-profile:{self.profile.value}" ) print( f"[STATS] Clients: {len(self.clients)} | " @@ -865,8 +777,8 @@ def main(): parser.add_argument( "--mbps", type=float, - default=5, - help="Target rate in Mbps (fixed rate if min/max not specified)", + default=None, + help="Fixed target Mbps in rate shape mode (default: 5)", ) parser.add_argument( "--min-mbps", @@ -878,39 +790,45 @@ def main(): "--max-mbps", type=float, default=None, - help="Maximum rate in Mbps for floating rate mode", + help="Rate-mode upper bound or optional profile-mode ceiling", + ) + parser.add_argument( + "--shape-mode", + choices=["rate", "profile"], + default="rate", + help="Offered-load contract (default: rate)", ) parser.add_argument( "--advanced", action="store_true", - help="Enable advanced masking (generator/obfuscator)", + help="Deprecated compatibility alias for --shape-mode profile", ) parser.add_argument( "--profile", choices=["web", "video", "voip", "file", "gaming", "mixed"], - default="mixed", - help="Traffic profile for advanced mode", + default=None, + help="Required experimental traffic profile in profile shape mode", ) parser.add_argument( "--header", choices=["none", "rtp", "quic"], default="none", - help="Pseudo-header type in advanced mode", + help="Pseudo-header type in profile mode", ) parser.add_argument( "--padding", choices=["random", "fixed_buckets", "progressive", "none"], default="random", - help="Padding strategy in advanced mode", + help="Padding strategy in profile mode", ) parser.add_argument( - "--mtu", type=int, default=1200, help="MTU for fragmentation in advanced mode" + "--mtu", type=int, default=1200, help="Maximum application UDP datagram size" ) parser.add_argument( "--entropy", type=float, default=1.0, - help="Payload entropy (0..1) for advanced mode", + help="Payload entropy compatibility setting for profile mode", ) parser.add_argument( "--stats-interval", @@ -959,6 +877,7 @@ def main(): max_mbps=args.max_mbps, advanced=args.advanced, profile=args.profile, + shape_mode=args.shape_mode, header=args.header, padding=args.padding, mtu=args.mtu, From ed4589a80ea2ba13167ac1115d611f564fd1174a Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:43:02 +0300 Subject: [PATCH 06/10] traffic-masking: isolate client pacing and uplink budgets --- traffic-masking/EXAMPLES.md | 32 ++- traffic-masking/README.md | 23 +- traffic-masking/SUMMARY.md | 35 +-- traffic-masking/masking_lib.py | 168 ++++++++++++++ traffic-masking/test_cli.py | 37 +++- traffic-masking/test_concurrency.py | 169 ++++++++++++++ traffic-masking/test_control_protocol.py | 18 +- traffic-masking/test_floating_rate.py | 59 +++++ traffic-masking/test_live.py | 59 ++++- traffic-masking/test_shaping.py | 7 +- traffic-masking/test_uplink.py | 106 +++++++++ traffic-masking/traffic_masking_client.py | 238 +++++++++++++------- traffic-masking/traffic_masking_server.py | 258 ++++++++++++++++------ 13 files changed, 1023 insertions(+), 186 deletions(-) create mode 100644 traffic-masking/test_concurrency.py create mode 100644 traffic-masking/test_floating_rate.py create mode 100644 traffic-masking/test_uplink.py diff --git a/traffic-masking/EXAMPLES.md b/traffic-masking/EXAMPLES.md index 731e64c..b3770fe 100644 --- a/traffic-masking/EXAMPLES.md +++ b/traffic-masking/EXAMPLES.md @@ -22,11 +22,31 @@ python traffic_masking_client.py --server 127.0.0.1 --response 0.3 --advanced \ Mbps values are decimal Mbit/s of application UDP payload. The client defaults to no scheduled uplink (`--response 0.0`). A nonzero response is an explicit -diagnostic/profile choice; the current standalone scheduler does not guarantee -that exact ratio on the wire. +diagnostic/profile choice. Its ratio covers successfully submitted DATA, +framing, padding and keepalive bytes relative to authenticated downlink datagram +bytes; mandatory keepalives can temporarily exceed the target and are repaid by +pausing DATA. Server `rate` mode supplies demand to reach its configured target. Experimental `profile` mode preserves native event sizes and gaps; `--max-mbps` only caps it. +Rates are per validated client. `--max-total-mbps` is an actual aggregate cap; +when it binds, validated clients share it in round-robin order. + +Client health and reporting timings support CLI flags and environment defaults: + +```bash +TRAFFIC_MASKING_KEEPALIVE_INTERVAL=2 \ +TRAFFIC_MASKING_RECEIVE_TIMEOUT=8 \ +TRAFFIC_MASKING_RECONNECT_DELAY_MIN=0.5 \ +TRAFFIC_MASKING_RECONNECT_DELAY_MAX=10 \ +TRAFFIC_MASKING_STATS_INTERVAL=2 \ +python traffic_masking_client.py --server SERVER_IP \ + --psk-file ./traffic-masking.psk +``` + +`TRAFFIC_MASKING_KEEPALIVE_JITTER` sets the fractional jitter (default `0.2`). +Equivalent CLI flags override these defaults. The receive timeout must be +greater than `keepalive interval * (1 + jitter)`. ## Use Cases @@ -168,11 +188,9 @@ sudo iftop -i eth0 -f "udp port 8888" htop pidstat -p $(pgrep -f traffic_masking_server) 1 -# Extract rates from logs -grep "Rate:" server.log | awk '{print $4}' | sort -n - -# Average rate -grep "Rate:" server.log | awk '{sum+=$4; count++} END {print sum/count}' +# Total and per-client rates are labelled separately +grep "Total Rate:" server.log +grep "Per-client:" server.log ``` ## Troubleshooting diff --git a/traffic-masking/README.md b/traffic-masking/README.md index bf2be21..13f26b1 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -92,8 +92,9 @@ make test - `--max-mbps`: In profile mode, an optional ceiling that only adds delay - `--advanced`: Deprecated warning-emitting alias for profile mode - `--response`: Optional diagnostic/profile uplink setting (0.0-1.0, default - 0.0). Nonzero values request additional uplink traffic; the current standalone - scheduler does not guarantee that exact ratio on the wire. + 0.0). The client budgets successfully submitted framed uplink bytes as this + fraction of authenticated downlink datagram bytes. DATA, framing, padding and + keepalives share the budget; mandatory keepalives can create temporary debt. - `--header`: Pseudo-headers (none/rtp/quic) - `--padding`: Padding strategy (none/random/fixed_buckets/progressive) - `--entropy`: Payload entropy (0.0-1.0) @@ -102,11 +103,25 @@ make test IP and outer encrypted-transport overhead when selecting a path-safe value. - `--psk-file`: Path to the shared 32-4096 byte binary key. The file must not grant group or other permissions. -- `--max-clients`, `--max-total-mbps`: Bound authenticated enrollment and the - configured aggregate egress commitment. +- `--max-clients`, `--max-total-mbps`: Bound authenticated enrollment and actual + aggregate server egress. The configured rate is per client; a round-robin + global limiter shares a binding total cap between validated clients. - `--max-handshakes-per-second`: Bound global handshake processing. Pending and replay state expires with the cookie window; full state refuses new enrollment rather than evicting an authenticated client. +- `--keepalive-interval`, `--keepalive-jitter`, `--receive-timeout`: Control + client health checks. The receive timeout must exceed the maximum jittered + keepalive interval. +- `--reconnect-delay-min`, `--reconnect-delay-max`: Bound exponential reconnect + backoff. +- `--stats-interval`: Controls reporting on both endpoints. Server reports + explicitly labelled total and per-client framed application-datagram rates. + +Client timing defaults can also be set with +`TRAFFIC_MASKING_KEEPALIVE_INTERVAL`, `TRAFFIC_MASKING_KEEPALIVE_JITTER`, +`TRAFFIC_MASKING_RECEIVE_TIMEOUT`, `TRAFFIC_MASKING_RECONNECT_DELAY_MIN`, and +`TRAFFIC_MASKING_RECONNECT_DELAY_MAX`. `TRAFFIC_MASKING_STATS_INTERVAL` applies +to either endpoint. CLI values override environment defaults. `--insecure-diagnostic` uses a public built-in key and is only for local diagnostics. Production startup fails closed when the PSK is missing, diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index 8e18d6d..34a90b6 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -9,6 +9,8 @@ - `DynamicObfuscator`: Packet obfuscation and fragmentation - `ShapeEvent`, `Packetizer`, `RateLimiter`: Explicit offered-load, application-packetization, and framed-byte pacing contracts +- `FloatingRate`: Bounded slope-limited per-client rate state +- `RatioBudget`: Successful framed uplink accounting against downlink bytes - `ProtocolMimicry`: Pattern generation for different traffic profiles - `TrafficProfile`: Enum for supported profiles (web, video, voip, file, gaming, mixed) @@ -20,15 +22,16 @@ - Restrictive PSK file validation **traffic_masking_server.py** -- Multi-client UDP server with batch processing -- Authenticated client enrollment with client, handshake-rate, and total-rate caps +- Multi-client UDP server with independent generator, limiter, RNG and counters +- Authenticated client enrollment with client and handshake-rate caps +- Round-robin per-client pacing under an actual aggregate egress limiter - Explicit fixed/floating rate mode and experimental native profile mode - Real-time statistics monitoring **traffic_masking_client.py** - Authenticated challenge/response handshake and source validation -- Adaptive uplink generation based on downlink rate -- Response ratio control (0-100% of received traffic) +- Monotonic receive-rate windows and configurable health/reconnect timings +- Response ratio control over DATA, framing, padding and control bytes ### Enhanced Modules (optional) - `enhanced/timing.py`: Adaptive timing with congestion modeling @@ -41,21 +44,19 @@ ### Floating Rate Algorithm ```python -# Physics-based smooth rate transitions -rate_acceleration = random.uniform(-0.5, 0.5) # Major pattern changes -rate_velocity += rate_acceleration * dt -rate_velocity *= 0.95 # Natural damping -current_mbps += rate_velocity * dt - -# Elastic boundaries -if current_mbps < min_mbps: - current_mbps = min_mbps + elastic_bounce - rate_velocity = abs(rate_velocity) * 0.5 +# Low-pass random slope with midpoint reversion +desired_slope = midpoint_force + bounded_noise +slope += (desired_slope - slope) * elapsed / response_time +slope = clamp(slope, -max_slope, max_slope) +current_mbps += slope * elapsed + +# Soft reflection avoids exact-boundary dwell +current_mbps = reflect_inside(current_mbps, min_mbps, max_mbps) ``` -- Pattern changes every 2-8 seconds -- Momentum-based transitions for realistic traffic -- Elastic collision at min/max boundaries +- Monotonic-clock updates with injected RNG for deterministic tests +- Bounded derivative and nonzero long-run variance +- Independent state and sequence for every validated client ### Performance Optimizations - Batch processing: 10 packets per batch diff --git a/traffic-masking/masking_lib.py b/traffic-masking/masking_lib.py index 492baee..6fe9745 100644 --- a/traffic-masking/masking_lib.py +++ b/traffic-masking/masking_lib.py @@ -21,6 +21,7 @@ import struct import random import socket +import threading import time import math from collections.abc import Callable, Iterator, Sequence @@ -51,6 +52,8 @@ "PatternStep", "ShapeEvent", "Packetizer", + "FloatingRate", + "RatioBudget", "RateLimiter", "RateReservation", "ProtocolMimicry", @@ -137,6 +140,171 @@ def packetize(self, payload): ) +class FloatingRate: + """Bounded, slope-limited rate process driven by a monotonic clock.""" + + def __init__( + self, + minimum_mbps, + maximum_mbps, + clock=None, + rng=None, + max_slope_mbps_per_second=None, + response_time=1.5, + ): + try: + minimum_mbps = float(minimum_mbps) + maximum_mbps = float(maximum_mbps) + response_time = float(response_time) + except (TypeError, ValueError): + raise ValueError("floating rate bounds must be finite numbers") from None + if ( + not math.isfinite(minimum_mbps) + or not math.isfinite(maximum_mbps) + or minimum_mbps <= 0 + or minimum_mbps >= maximum_mbps + ): + raise ValueError("floating rate bounds must be positive and ordered") + if not math.isfinite(response_time) or response_time <= 0: + raise ValueError("floating rate response time must be positive") + + span = maximum_mbps - minimum_mbps + if max_slope_mbps_per_second is None: + max_slope_mbps_per_second = span / 4 + try: + max_slope_mbps_per_second = float(max_slope_mbps_per_second) + except (TypeError, ValueError): + raise ValueError("floating rate slope must be positive") from None + if ( + not math.isfinite(max_slope_mbps_per_second) + or max_slope_mbps_per_second <= 0 + ): + raise ValueError("floating rate slope must be positive") + + self.minimum_mbps = minimum_mbps + self.maximum_mbps = maximum_mbps + self.max_slope_mbps_per_second = max_slope_mbps_per_second + self.response_time = response_time + self._span = span + self._midpoint = (minimum_mbps + maximum_mbps) / 2 + self._clock = clock or time.monotonic + self._rng = rng or random.Random() + self._updated_at = self._clock() + self.value_mbps = self._midpoint + self.slope_mbps_per_second = self._rng.uniform( + -self.max_slope_mbps_per_second, + self.max_slope_mbps_per_second, + ) + minimum_starting_slope = self.max_slope_mbps_per_second * 0.05 + if abs(self.slope_mbps_per_second) < minimum_starting_slope: + self.slope_mbps_per_second = minimum_starting_slope + + def update(self): + """Advance to the injected clock and return the current rate in Mbps.""" + now = self._clock() + elapsed = max(0.0, now - self._updated_at) + self._updated_at = now + if elapsed == 0: + return self.value_mbps + + center_slope = ( + (self._midpoint - self.value_mbps) + / self._span + * self.max_slope_mbps_per_second + ) + noise_slope = self._rng.uniform( + -self.max_slope_mbps_per_second, + self.max_slope_mbps_per_second, + ) + desired_slope = center_slope * 0.6 + noise_slope * 0.4 + blend = min(1.0, elapsed / self.response_time) + slope = self.slope_mbps_per_second + ( + desired_slope - self.slope_mbps_per_second + ) * blend + slope = max( + -self.max_slope_mbps_per_second, + min(self.max_slope_mbps_per_second, slope), + ) + candidate = self.value_mbps + slope * elapsed + + # Reflect overshoot into the range and reduce momentum at the edge. The + # epsilon keeps samples away from an exact-boundary dwell. + epsilon = self._span * 1e-9 + for _ in range(8): + if candidate < self.minimum_mbps: + candidate = self.minimum_mbps + ( + self.minimum_mbps - candidate + ) * 0.5 + slope = abs(slope) * 0.5 + elif candidate > self.maximum_mbps: + candidate = self.maximum_mbps - ( + candidate - self.maximum_mbps + ) * 0.5 + slope = -abs(slope) * 0.5 + else: + break + self.value_mbps = min( + self.maximum_mbps - epsilon, + max(self.minimum_mbps + epsilon, candidate), + ) + self.slope_mbps_per_second = slope + return self.value_mbps + + +class RatioBudget: + """Track successful uplink bytes against a fraction of downlink bytes.""" + + def __init__(self, ratio): + try: + ratio = float(ratio) + except (TypeError, ValueError): + raise ValueError("ratio must be in [0.0, 1.0]") from None + if not math.isfinite(ratio) or not 0.0 <= ratio <= 1.0: + raise ValueError("ratio must be in [0.0, 1.0]") + self.ratio = ratio + self.downlink_bytes = 0 + self.uplink_bytes = 0 + self._lock = threading.Lock() + + @staticmethod + def _validate_byte_count(byte_count): + if ( + isinstance(byte_count, bool) + or not isinstance(byte_count, int) + or byte_count < 0 + ): + raise ValueError("byte count must be a non-negative integer") + return byte_count + + @property + def available_bytes(self): + with self._lock: + return max(0.0, self.downlink_bytes * self.ratio - self.uplink_bytes) + + @property + def observed_ratio(self): + with self._lock: + if self.downlink_bytes == 0: + return 0.0 + return self.uplink_bytes / self.downlink_bytes + + def record_downlink(self, byte_count): + byte_count = self._validate_byte_count(byte_count) + with self._lock: + self.downlink_bytes += byte_count + + def allows(self, byte_count, allow_debt=False): + byte_count = self._validate_byte_count(byte_count) + with self._lock: + available = self.downlink_bytes * self.ratio - self.uplink_bytes + return allow_debt or byte_count <= max(0.0, available) + + def record_uplink(self, byte_count): + byte_count = self._validate_byte_count(byte_count) + with self._lock: + self.uplink_bytes += byte_count + + @dataclass(frozen=True) class RateReservation: byte_count: int diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index 100d06d..8c1b378 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -5,6 +5,7 @@ import subprocess import sys +import os import pytest @@ -59,6 +60,9 @@ def test_server_accepts_valid_floating_config(): {"stats_interval": float("inf")}, {"mtu": float("inf")}, {"mtu": 1200.5}, + {"keepalive_interval": 0}, + {"keepalive_interval": 5, "receive_timeout": 6}, + {"reconnect_delay_min": 3, "reconnect_delay_max": 2}, ], ) def test_client_rejects_bad_config(kwargs): @@ -71,12 +75,13 @@ def test_client_default_response_is_download_only(): assert client.response_ratio == 0.0 -def _run(script, *args): +def _run(script, *args, env=None): return subprocess.run( [sys.executable, script, *args], capture_output=True, text=True, timeout=15, + env=env, ) @@ -141,6 +146,22 @@ def _run(script, *args): ), "stats-interval must be a positive finite number", ), + ( + CLIENT, + ( + "--server", "127.0.0.1", "--insecure-diagnostic", + "--keepalive-interval", "5", "--receive-timeout", "6", + ), + "maximum jittered keepalive interval", + ), + ( + CLIENT, + ( + "--server", "127.0.0.1", "--insecure-diagnostic", + "--reconnect-delay-min", "3", "--reconnect-delay-max", "2", + ), + "reconnect-delay-min must not exceed", + ), ], ) def test_invalid_cli_exits_2_with_useful_message(script, args, message): @@ -157,6 +178,20 @@ def test_cli_requires_psk_unless_diagnostic(script): assert "--psk-file is required" in result.stderr +def test_timing_environment_defaults_are_validated(): + env = os.environ.copy() + env["TRAFFIC_MASKING_RECEIVE_TIMEOUT"] = "0" + result = _run( + CLIENT, + "--server", + "127.0.0.1", + "--insecure-diagnostic", + env=env, + ) + assert result.returncode == 2 + assert "receive-timeout must be a positive finite number" in result.stderr + + def test_server_rejects_limits_below_per_client_rate(): with pytest.raises(ValueError, match="max-total-mbps"): MaskingTrafficServer(target_mbps=5, max_total_mbps=4, psk=TEST_PSK) diff --git a/traffic-masking/test_concurrency.py b/traffic-masking/test_concurrency.py new file mode 100644 index 0000000..3149358 --- /dev/null +++ b/traffic-masking/test_concurrency.py @@ -0,0 +1,169 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Per-client shaping isolation and aggregate-cap fairness.""" + +from types import SimpleNamespace + +import pytest + +from conftest import TEST_PSK +from masking_lib import ShapeEvent, mbps_to_bytes_per_second +from traffic_masking_server import MaskingTrafficServer + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +class RecordingSocket: + def __init__(self): + self.sent = [] + + def sendto(self, datagram, address): + self.sent.append((bytes(datagram), address)) + return len(datagram) + + +def make_server(clock, **kwargs): + server = MaskingTrafficServer( + target_mbps=1, + psk=TEST_PSK, + clock=clock, + monotonic_clock=clock, + sleep=clock.advance, + byte_source=lambda size: b"n" * size, + cookie_secret=b"z" * 32, + **kwargs, + ) + server.socket = RecordingSocket() + return server + + +def add_client(server, marker): + frame = SimpleNamespace( + client_nonce=bytes([marker]) * 16, + session_nonce=bytes([marker + 16]) * 16, + sequence=1, + ) + client = server._new_client_state( + frame, + server._clock(), + TEST_PSK, + TEST_PSK, + ) + address = ("127.0.0.1", 20000 + marker) + server.clients[address] = client + return address, client + + +def exercise_round_robin(server, clients, rounds=400): + fragment = b"d" * server.data_payload_ceiling + for _ in range(rounds): + for address, client in clients: + server._send_fragment(address, client, fragment) + + +def test_clients_own_independent_generator_limiter_rng_and_counters(): + clock = FakeClock() + server = MaskingTrafficServer( + min_mbps=2, + max_mbps=6, + max_total_mbps=12, + psk=TEST_PSK, + clock=clock, + monotonic_clock=clock, + sleep=clock.advance, + byte_source=lambda size: b"n" * size, + cookie_secret=b"z" * 32, + ) + first = add_client(server, 1)[1] + second = add_client(server, 2)[1] + + assert first["packet_gen"] is not second["packet_gen"] + assert first["rate_limiter"] is not second["rate_limiter"] + assert first["floating_rate"] is not second["floating_rate"] + first_rates = [] + second_rates = [] + for _ in range(20): + clock.advance(0.1) + server._next_shape_event(first) + server._next_shape_event(second) + first_rates.append(first["current_rate_mbps"]) + second_rates.append(second["current_rate_mbps"]) + + assert first_rates != second_rates + assert all(2 < value < 6 for value in first_rates + second_rates) + + +def test_two_clients_share_binding_total_cap_fairly_without_exceeding_it(): + clock = FakeClock() + server = make_server(clock, max_total_mbps=1.5) + clients = [add_client(server, 1), add_client(server, 2)] + + exercise_round_robin(server, clients) + + total_bytes = server.stats["bytes_sent"] + total_cap = mbps_to_bytes_per_second(server.max_total_mbps) + assert total_bytes <= total_cap * clock.now + server.mtu + 1 + sustained_total = (total_bytes - server.mtu) / clock.now + assert sustained_total == pytest.approx(total_cap, rel=0.02) + first_bytes = clients[0][1]["bytes_sent"] + second_bytes = clients[1][1]["bytes_sent"] + assert first_bytes == second_bytes + assert abs(first_bytes - second_bytes) / first_bytes <= 0.05 + + +def test_two_clients_each_track_target_when_total_cap_is_not_binding(): + clock = FakeClock() + server = make_server(clock, max_total_mbps=3) + clients = [add_client(server, 3), add_client(server, 4)] + + exercise_round_robin(server, clients) + + for _, client in clients: + sustained = (client["bytes_sent"] - server.mtu) / clock.now + assert sustained == pytest.approx(mbps_to_bytes_per_second(1), rel=0.03) + + +def test_profile_gap_starts_after_the_last_fragment_is_submitted(): + clock = FakeClock() + server = MaskingTrafficServer( + shape_mode="profile", + profile="voip", + padding="none", + max_total_mbps=100, + psk=TEST_PSK, + clock=clock, + monotonic_clock=clock, + sleep=clock.advance, + byte_source=lambda size: b"n" * size, + cookie_secret=b"z" * 32, + ) + server.socket = RecordingSocket() + address, client = add_client(server, 5) + client["generator"] = iter( + [ + ShapeEvent(server.data_payload_ceiling * 2, delay=0.5), + ShapeEvent(100, delay=0), + ] + ) + + for _ in range(2): + fragment = server._next_client_fragment(client) + assert fragment is not None + server._send_fragment(address, client, fragment) + server._complete_client_fragment(client) + + completed_at = clock.now + assert client["next_event_at"] == pytest.approx(completed_at + 0.5) + assert server._next_client_fragment(client) is None + clock.advance(0.5) + assert server._next_client_fragment(client) is not None diff --git a/traffic-masking/test_control_protocol.py b/traffic-masking/test_control_protocol.py index a6a658a..0462511 100644 --- a/traffic-masking/test_control_protocol.py +++ b/traffic-masking/test_control_protocol.py @@ -354,9 +354,9 @@ def test_expired_auth_fails_and_prevalidation_is_non_amplifying(): @pytest.mark.parametrize( ("max_clients", "max_total_mbps"), - [(1, 100), (2, 1)], + [(1, 100)], ) -def test_client_and_total_caps_refuse_new_enrollment( +def test_client_count_cap_refuses_new_enrollment( max_clients, max_total_mbps ): server = make_server( @@ -383,6 +383,20 @@ def test_client_and_total_caps_refuse_new_enrollment( assert len(server.clients) == 1 +def test_total_rate_cap_allows_enrollment_for_fair_runtime_sharing(): + server = make_server(max_clients=2, max_total_mbps=1) + complete_handshake(server, ("127.0.0.1", 20006)) + complete_handshake( + server, + ("127.0.0.1", 20007), + client_nonce=b"d" * NONCE_SIZE, + hello_sequence=30, + ) + + assert len(server.clients) == 2 + assert server.total_rate_limiter.rate_bytes_per_second == 125_000 + + def test_handshake_rate_and_pending_state_are_bounded(): clock = FakeClock() server = make_server( diff --git a/traffic-masking/test_floating_rate.py b/traffic-masking/test_floating_rate.py new file mode 100644 index 0000000..b87b010 --- /dev/null +++ b/traffic-masking/test_floating_rate.py @@ -0,0 +1,59 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Deterministic contracts for bounded floating-rate state.""" + +import random +import statistics + +import pytest + +from masking_lib import FloatingRate + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +def test_floating_rate_moves_early_stays_bounded_and_has_variance(): + clock = FakeClock() + process = FloatingRate(2.0, 6.0, clock=clock, rng=random.Random(1042)) + values = [process.value_mbps] + + for _ in range(600): + clock.advance(0.1) + values.append(process.update()) + + assert all(2.0 < value < 6.0 for value in values) + assert any(value != values[0] for value in values[1:21]) + derivatives = [ + abs(current - previous) / 0.1 + for previous, current in zip(values, values[1:]) + ] + assert max(derivatives) <= process.max_slope_mbps_per_second + 1e-9 + assert statistics.pvariance(values[20:]) > 0.001 + assert 2.0 not in values and 6.0 not in values + + +@pytest.mark.parametrize( + "kwargs", + [ + {"minimum_mbps": 0, "maximum_mbps": 1}, + {"minimum_mbps": 2, "maximum_mbps": 1}, + { + "minimum_mbps": 1, + "maximum_mbps": 2, + "max_slope_mbps_per_second": 0, + }, + ], +) +def test_floating_rate_rejects_invalid_bounds_and_slope(kwargs): + with pytest.raises(ValueError): + FloatingRate(**kwargs) diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index 9c5447b..e31bd27 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -5,8 +5,7 @@ Ported from the former standalone test_traffic_masking.py and test_realistic_patterns.py runners so nothing runs outside pytest. Bounded to stay -CI-safe. Durations are dictated by the current hard-coded keepalive/receive -timeouts; a later stage adds timing knobs to shrink them. +CI-safe. Reconnection cases use explicit short keepalive/receive/backoff values. """ import re @@ -36,6 +35,16 @@ def _server_args(port, psk_file, lo=2, hi=4): ] +def _fast_client_timings(): + return [ + "--keepalive-interval", "0.2", + "--keepalive-jitter", "0", + "--receive-timeout", "0.8", + "--reconnect-delay-min", "0.2", + "--reconnect-delay-max", "0.5", + ] + + def test_transmission_bidirectional(spawn, start_server, psk_file): """Client connects, receives downlink and emits uplink; server sees the client.""" server, port = start_server( @@ -73,7 +82,8 @@ def server_args(selected_port): client = spawn( CLIENT, ["--server", "127.0.0.1", "--port", str(port), "--response", "0.3", - "--stats-interval", "1", "--psk-file", str(psk_file)], + "--stats-interval", "1", "--psk-file", str(psk_file), + *_fast_client_timings()], "client", ) assert wait_for(client, "Rx:", 10.0), read_log(client) @@ -81,7 +91,7 @@ def server_args(selected_port): # Phase 2: kill the server; the client must detect loss and must NOT falsely # report a reconnect while the server is down. stop_process(server) - assert wait_for(client, "Connection lost", 20.0), read_log(client) + assert wait_for(client, "Connection lost", 5.0), read_log(client) downtime = read_log(client).split("Connection lost", 1)[1] assert "Reconnected successfully" not in downtime, read_log(client) @@ -89,7 +99,7 @@ def server_args(selected_port): reconnect_offset = client.mark_log() server2, _ = start_server(server_args, "server2", port=port) assert wait_for( - client, "Reconnected successfully", 25.0, offset=reconnect_offset + client, "Reconnected successfully", 8.0, offset=reconnect_offset ), read_log(client, offset=reconnect_offset) assert server2.process.poll() is None @@ -170,6 +180,45 @@ def test_floating_rate_stays_within_bounds(spawn, start_server, psk_file): assert max(rates) <= hi * 1.75, rates +def test_two_clients_share_total_cap_with_bounded_fairness( + spawn, start_server, psk_file +): + cap = 1.5 + server, port = start_server( + lambda selected_port: [ + "--host", "127.0.0.1", "--port", str(selected_port), + "--mbps", "1", "--max-total-mbps", str(cap), + "--stats-interval", "1", "--psk-file", str(psk_file), + ], + "fair-server", + ) + clients = [ + spawn( + CLIENT, + [ + "--server", "127.0.0.1", "--port", str(port), + "--stats-interval", "1", "--psk-file", str(psk_file), + ], + f"fair-client-{index}", + ) + for index in range(2) + ] + for client in clients: + assert wait_for(client, "Authenticated session accepted", 5.0), read_log( + client + ) + + time.sleep(5) + client_rates = [ + last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") for client in clients + ] + total_rate = last_match(server, r"Total Rate:\s*([0-9.]+)\s*Mbps") + assert all(rate is not None and rate > 0.4 for rate in client_rates) + assert total_rate is not None and total_rate <= cap * 1.15 + assert abs(client_rates[0] - client_rates[1]) <= max(client_rates) * 0.35 + assert "Per-client:" in read_log(server) + + def test_raw_probe_gets_only_bounded_challenge(start_server, psk_file): server, port = start_server( lambda selected_port: [ diff --git a/traffic-masking/test_shaping.py b/traffic-masking/test_shaping.py index dcfc285..7cf33bf 100644 --- a/traffic-masking/test_shaping.py +++ b/traffic-masking/test_shaping.py @@ -126,9 +126,11 @@ def sendto(self, datagram, address): return len(datagram) -def test_client_packetizes_large_uplink_before_session_framing(): +def test_client_packetizes_credited_uplink_before_session_framing(): key = b"k" * 32 - client = AdaptiveTrafficClient("server.example", 8888, psk=key, mtu=1200) + client = AdaptiveTrafficClient( + "server.example", 8888, psk=key, mtu=1200, response_ratio=1.0 + ) client.socket = RecordingSocket() client.server_addr = ("192.0.2.1", 8888) client.client_nonce = b"c" * 16 @@ -138,6 +140,7 @@ def test_client_packetizes_large_uplink_before_session_framing(): ) client.handshake_accepted = True payload = b"u" * 9_000 + client.uplink_budget.record_downlink(20_000) client.send_packet(payload) diff --git a/traffic-masking/test_uplink.py b/traffic-masking/test_uplink.py new file mode 100644 index 0000000..104e532 --- /dev/null +++ b/traffic-masking/test_uplink.py @@ -0,0 +1,106 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Uplink ratio accounting includes framed DATA and padded control bytes.""" + +import random + +import pytest + +from conftest import TEST_PSK +from control_protocol import CLIENT_TO_SERVER, FRAME_OVERHEAD, MessageType, derive_session_key +from traffic_masking_client import AdaptiveTrafficClient + + +class FakeClock: + def __init__(self): + self.now = 0.0 + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +class RecordingSocket: + def __init__(self): + self.sent = [] + + def sendto(self, datagram, address): + self.sent.append((bytes(datagram), address)) + return len(datagram) + + +def connected_client(clock, response_ratio=0.25): + client = AdaptiveTrafficClient( + "server.example", + 8888, + response_ratio=response_ratio, + psk=TEST_PSK, + rng=random.Random(44), + byte_source=lambda size: b"u" * size, + monotonic_clock=clock, + sleep=clock.advance, + ) + client.socket = RecordingSocket() + client.server_addr = ("192.0.2.10", 8888) + client.client_nonce = b"c" * 16 + client.session_nonce = b"s" * 16 + client.session_send_key = derive_session_key( + TEST_PSK, + client.client_nonce, + client.session_nonce, + CLIENT_TO_SERVER, + ) + client.handshake_accepted = True + return client + + +def test_received_rate_uses_actual_monotonic_elapsed_time(): + clock = FakeClock() + client = connected_client(clock) + + clock.advance(2.5) + client._record_received_data(125_000) + + assert client.received_rate == pytest.approx(0.4) + + +def test_data_keepalive_framing_and_padding_share_one_uplink_budget(): + clock = FakeClock() + client = connected_client(clock, response_ratio=0.25) + client._record_received_data(120_000) + + keepalive_bytes = client._send_session_message( + MessageType.KEEPALIVE, allow_budget_debt=True + ) + while client.uplink_budget.available_bytes >= FRAME_OVERHEAD + 200: + packet_size = min( + 500, + int(client.uplink_budget.available_bytes) - FRAME_OVERHEAD, + ) + assert client.send_packet(client.generate_response_packet(packet_size)) > 0 + + observed_bytes = sum(len(datagram) for datagram, _ in client.socket.sent) + assert keepalive_bytes > FRAME_OVERHEAD + assert observed_bytes == client.stats["bytes_sent"] + assert observed_bytes == client.uplink_budget.uplink_bytes + assert client.uplink_budget.observed_ratio <= client.response_ratio + assert ( + client.response_ratio - client.uplink_budget.observed_ratio + < client.mtu / client.uplink_budget.downlink_bytes + ) + + +def test_uncredited_data_cannot_bypass_budget_but_keepalive_can_create_debt(): + clock = FakeClock() + client = connected_client(clock, response_ratio=0.0) + sequence = client.control_send_sequence + + assert client.send_packet(client.generate_response_packet(500)) == 0 + assert client.control_send_sequence == sequence + assert client._send_session_message( + MessageType.KEEPALIVE, allow_budget_debt=True + ) > 0 + assert client.uplink_budget.available_bytes == 0 diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index 6b6664a..cf6185f 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -43,21 +43,20 @@ from masking_lib import ( ObfuscationConfig, Packetizer, + RatioBudget, build_obfuscator, init_udp_socket, - mbps_to_bytes_per_second, parse_profile, ) +def _env_default(name, fallback): + return os.environ.get(name, fallback) + + class AdaptiveTrafficClient: """Adaptive traffic masking client""" - KEEPALIVE_INTERVAL = 5.0 # Send keepalive every 5 seconds - RECEIVE_TIMEOUT = 10.0 # Consider connection lost after 10s without data - RECONNECT_DELAY_MIN = 1.0 - RECONNECT_DELAY_MAX = 30.0 - def __init__( self, server_host, @@ -75,6 +74,12 @@ def __init__( psk=None, insecure_diagnostic=False, keepalive_jitter=0.2, + keepalive_interval=5.0, + receive_timeout=10.0, + reconnect_delay_min=1.0, + reconnect_delay_max=30.0, + monotonic_clock=None, + sleep=None, ): # Validate configuration up front; fail fast on invalid inputs. try: @@ -118,6 +123,32 @@ def __init__( raise ValueError("keepalive jitter must be a number") from None if not math.isfinite(keepalive_jitter) or not 0.0 <= keepalive_jitter < 1.0: raise ValueError("keepalive jitter must be in [0.0, 1.0)") + timing_values = { + "keepalive-interval": keepalive_interval, + "receive-timeout": receive_timeout, + "reconnect-delay-min": reconnect_delay_min, + "reconnect-delay-max": reconnect_delay_max, + } + for name, value in timing_values.items(): + try: + value = float(value) + except (TypeError, ValueError): + raise ValueError(f"{name} must be a positive finite number") from None + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} must be a positive finite number") + timing_values[name] = value + keepalive_interval = timing_values["keepalive-interval"] + receive_timeout = timing_values["receive-timeout"] + reconnect_delay_min = timing_values["reconnect-delay-min"] + reconnect_delay_max = timing_values["reconnect-delay-max"] + if reconnect_delay_min > reconnect_delay_max: + raise ValueError( + "reconnect-delay-min must not exceed reconnect-delay-max" + ) + if receive_timeout <= keepalive_interval * (1.0 + keepalive_jitter): + raise ValueError( + "receive-timeout must exceed the maximum jittered keepalive interval" + ) if psk is not None and insecure_diagnostic: raise ValueError("psk and insecure diagnostic mode are mutually exclusive") if psk is None and not insecure_diagnostic: @@ -142,21 +173,29 @@ def __init__( self.running = False self.connected = False self.last_received = 0.0 + self._monotonic_clock = monotonic_clock or time.monotonic + self._sleep = sleep or time.sleep self.stats = { "bytes_received": 0, "bytes_sent": 0, "packets_received": 0, "packets_sent": 0, - "start_time": time.time(), + "start_time": self._monotonic_clock(), } self.received_rate = 0 self.rate_window = [] + self._rate_window_started = self._monotonic_clock() + self._rate_window_bytes = 0 self.sequence = 0 self._rng = rng or random.Random() self._byte_source = byte_source or os.urandom self.base_key = psk if psk is not None else INSECURE_DIAGNOSTIC_KEY self.insecure_diagnostic = bool(insecure_diagnostic) self.keepalive_jitter = keepalive_jitter + self.keepalive_interval = keepalive_interval + self.receive_timeout = receive_timeout + self.reconnect_delay_min = reconnect_delay_min + self.reconnect_delay_max = reconnect_delay_max self.mtu = mtu self.packetizer = Packetizer(mtu, FRAME_OVERHEAD) self.data_payload_ceiling = self.packetizer.payload_ceiling @@ -172,6 +211,7 @@ def __init__( self.control_receive_sequence = -1 self.handshake_accepted = False self.stats_interval = stats_interval + self.uplink_budget = RatioBudget(response_ratio) # Advanced obfuscation settings self.advanced = bool(advanced) self.obf_cfg = ObfuscationConfig( @@ -222,6 +262,9 @@ def _reset_protocol_state(self): self.control_receive_sequence = -1 self.handshake_accepted = False self.connected = False + self.uplink_budget = RatioBudget(self.response_ratio) + self._rate_window_started = self._monotonic_clock() + self._rate_window_bytes = 0 def _control_padding(self): return make_padding( @@ -339,16 +382,18 @@ def _process_datagram(self, datagram, addr): self.control_receive_sequence = frame.sequence return frame.payload - def _send_session_message(self, message_type, payload=b""): + def _send_session_message( + self, message_type, payload=b"", allow_budget_debt=False + ): if not self.handshake_accepted or self.session_send_key is None: - return False + return 0 with self._send_lock: - self.control_send_sequence += 1 + next_sequence = self.control_send_sequence + 1 datagram = encode_frame( message_type, self.client_nonce, self.session_nonce, - self.control_send_sequence, + next_sequence, self.session_send_key, payload=payload, padding=( @@ -357,48 +402,54 @@ def _send_session_message(self, message_type, payload=b""): else b"" ), ) + if not self.uplink_budget.allows( + len(datagram), allow_debt=allow_budget_debt + ): + return 0 + self.control_send_sequence = next_sequence try: sent = self.socket.sendto(datagram, self.server_addr) except OSError as exc: print(f"[!] Send error: {exc}", flush=True) - return False + return 0 if sent != len(datagram): - return False + return 0 self.stats["bytes_sent"] += sent self.stats["packets_sent"] += 1 - return True + self.uplink_budget.record_uplink(sent) + return sent def _next_keepalive_delay(self): factor = 1.0 + self._rng.uniform( -self.keepalive_jitter, self.keepalive_jitter ) - return self.KEEPALIVE_INTERVAL * factor + return self.keepalive_interval * factor def _wait_for_server(self, timeout=5.0): """Wait for actual data from the server to confirm connection""" - deadline = time.time() + timeout - while time.time() < deadline and self.running: + deadline = self._monotonic_clock() + timeout + while self._monotonic_clock() < deadline and self.running: if self.connected: return True - time.sleep(0.2) + self._sleep(min(0.2, timeout)) return self.connected def _reconnect(self): """Reconnect to the server with exponential backoff""" - delay = self.RECONNECT_DELAY_MIN + delay = self.reconnect_delay_min while self.running: print( f"[*] Attempting reconnect in {delay:.1f}s...", flush=True, ) - time.sleep(delay) + self._sleep(delay) if not self.running: break try: self._create_socket() self._send_registration() # Wait for actual server response to confirm connection - if self._wait_for_server(timeout=delay + 2.0): + if self._wait_for_server(timeout=self.receive_timeout): print("[*] Reconnected successfully", flush=True) self.received_rate = 0 self.rate_window.clear() @@ -407,7 +458,7 @@ def _reconnect(self): print("[!] No response from server", flush=True) except Exception as e: print(f"[!] Reconnect failed: {e}", flush=True) - delay = min(delay * 2, self.RECONNECT_DELAY_MAX) + delay = min(delay * 2, self.reconnect_delay_max) def connect(self): """Connect to the server""" @@ -433,7 +484,7 @@ def connect(self): # Send initial registration packet self._send_registration() - self.last_received = time.time() # Grace period for initial connection + self.last_received = self._monotonic_clock() # Start threads threading.Thread(target=self.receive_loop, daemon=True).start() @@ -470,21 +521,23 @@ def generate_response_packet(self, size=None): def send_packet(self, packet): """Send packet to the server""" + sent_bytes = 0 try: if self.advanced and self.obfuscator is not None: packet = self.obfuscator.transform( packet, profile=self.uplink_profile ) for fragment in self.packetizer.packetize(packet): - self._send_session_message(MessageType.DATA, fragment) + sent = self._send_session_message(MessageType.DATA, fragment) + if not sent: + break + sent_bytes += sent except Exception as e: print(f"[!] Send error: {e}", flush=True) + return sent_bytes def receive_loop(self): """Receive packets from the server""" - window_start = time.time() - window_bytes = 0 - while self.running: try: data, addr = self.socket.recvfrom(MAX_DATAGRAM_SIZE) @@ -493,45 +546,46 @@ def receive_loop(self): if payload is None: continue - self.last_received = time.time() - self.connected = True - self.stats["bytes_received"] += len(data) - self.stats["packets_received"] += 1 - - # Calculate receive rate - window_bytes += len(data) - current_time = time.time() - if current_time - window_start >= 1.0: # 1 second window - self.received_rate = window_bytes * 8 / 1_000_000 # decimal Mbps - self.rate_window.append(self.received_rate) - if len(self.rate_window) > 10: - self.rate_window.pop(0) - window_start = current_time - window_bytes = 0 - - # Occasionally send echo to simulate interactivity - if random.random() < 0.01: # 1% probability - echo_packet = self.generate_response_packet(len(payload) // 4) - self.send_packet(echo_packet) + self._record_received_data(len(data)) except socket.timeout: continue except Exception as e: if self.running: print(f"[!] Receive error: {e}", flush=True) - time.sleep(0.1) + self._sleep(0.1) + + def _record_received_data(self, byte_count): + now = self._monotonic_clock() + self.last_received = now + self.connected = True + self.stats["bytes_received"] += byte_count + self.stats["packets_received"] += 1 + self.uplink_budget.record_downlink(byte_count) + self._rate_window_bytes += byte_count + elapsed = now - self._rate_window_started + if elapsed >= 1.0: + self.received_rate = ( + self._rate_window_bytes * 8 / (elapsed * 1_000_000) + ) + self.rate_window.append(self.received_rate) + if len(self.rate_window) > 10: + self.rate_window.pop(0) + self._rate_window_started = now + self._rate_window_bytes = 0 def keepalive_loop(self): """Send periodic keepalives and handle reconnection""" while self.running: - time.sleep(self._next_keepalive_delay()) + self._sleep(self._next_keepalive_delay()) if not self.running: break # Check if we've lost the connection if ( self.last_received > 0 - and (time.time() - self.last_received) > self.RECEIVE_TIMEOUT + and (self._monotonic_clock() - self.last_received) + > self.receive_timeout ): print( "[!] Connection lost (no data received), reconnecting...", @@ -544,41 +598,32 @@ def keepalive_loop(self): continue if self.handshake_accepted: - self._send_session_message(MessageType.KEEPALIVE) + self._send_session_message( + MessageType.KEEPALIVE, allow_budget_debt=True + ) else: self._send_registration() def send_loop(self): - """Generate uplink traffic""" + """Spend response credit on framed DATA without bypass traffic.""" while self.running: - # Adaptive generation based on received traffic - if self.received_rate > 0: - # Send percentage of received rate - target_send_rate = mbps_to_bytes_per_second( - self.received_rate * self.response_ratio - ) # bytes/sec - - # Add random bursts - if random.random() < 0.05: # 5% burst probability - target_send_rate *= random.uniform(1.5, 3) - - # Generate packets - bytes_to_send = int(target_send_rate / 100) # Divide by send frequency - - while bytes_to_send > 0: - packet_size = min(bytes_to_send, random.randint(200, 1000)) - packet = self.generate_response_packet(packet_size) - self.send_packet(packet) - bytes_to_send -= len(packet) - time.sleep(random.uniform(0.001, 0.005)) - - time.sleep(0.01) # 100 Hz main loop + available_datagram_bytes = int(self.uplink_budget.available_bytes) + available_payload_bytes = available_datagram_bytes - FRAME_OVERHEAD + if available_payload_bytes >= 13: + packet_size = min( + available_payload_bytes, + self.data_payload_ceiling, + self._rng.randint(200, 1000), + ) + packet = self.generate_response_packet(packet_size) + self.send_packet(packet) + self._sleep(0.01) def stats_loop(self): """Print runtime statistics""" while self.running: - time.sleep(self.stats_interval) - elapsed = time.time() - self.stats["start_time"] + self._sleep(self.stats_interval) + elapsed = self._monotonic_clock() - self.stats["start_time"] if elapsed > 0: recv_mbps = (self.stats["bytes_received"] * 8) / (elapsed * 1_000_000) send_mbps = (self.stats["bytes_sent"] * 8) / (elapsed * 1_000_000) @@ -589,9 +634,11 @@ def stats_loop(self): conn_status = "connected" if self.connected else "disconnected" print( - f"[STATS] Rx: {recv_mbps:.2f} Mbps ({recv_pps:.0f} pps) | " + f"[STATS client total] Rx: {recv_mbps:.2f} Mbps " + f"({recv_pps:.0f} pps) | " f"Tx: {send_mbps:.2f} Mbps ({send_pps:.0f} pps) | " f"Avg rate: {avg_rate:.2f} Mbps | " + f"Uplink ratio: {self.uplink_budget.observed_ratio:.3f} | " f"Status: {conn_status}", flush=True, ) @@ -649,9 +696,39 @@ def main(): parser.add_argument( "--stats-interval", type=float, - default=5.0, + default=_env_default("TRAFFIC_MASKING_STATS_INTERVAL", 5.0), help="Stats print interval in seconds", ) + parser.add_argument( + "--keepalive-interval", + type=float, + default=_env_default("TRAFFIC_MASKING_KEEPALIVE_INTERVAL", 5.0), + help="Base keepalive interval in seconds", + ) + parser.add_argument( + "--keepalive-jitter", + type=float, + default=_env_default("TRAFFIC_MASKING_KEEPALIVE_JITTER", 0.2), + help="Fractional keepalive jitter in [0.0, 1.0)", + ) + parser.add_argument( + "--receive-timeout", + type=float, + default=_env_default("TRAFFIC_MASKING_RECEIVE_TIMEOUT", 10.0), + help="Seconds without authenticated data before reconnecting", + ) + parser.add_argument( + "--reconnect-delay-min", + type=float, + default=_env_default("TRAFFIC_MASKING_RECONNECT_DELAY_MIN", 1.0), + help="Initial reconnect delay in seconds", + ) + parser.add_argument( + "--reconnect-delay-max", + type=float, + default=_env_default("TRAFFIC_MASKING_RECONNECT_DELAY_MAX", 30.0), + help="Maximum reconnect delay in seconds", + ) auth_group = parser.add_mutually_exclusive_group() auth_group.add_argument( "--psk-file", @@ -680,6 +757,11 @@ def main(): stats_interval=args.stats_interval, psk=psk, insecure_diagnostic=args.insecure_diagnostic, + keepalive_jitter=args.keepalive_jitter, + keepalive_interval=args.keepalive_interval, + receive_timeout=args.receive_timeout, + reconnect_delay_min=args.reconnect_delay_min, + reconnect_delay_max=args.reconnect_delay_max, ) except ValueError as exc: parser.error(str(exc)) diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index ca2d90f..67a1a6f 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -47,6 +47,7 @@ ) from masking_lib import ( DynamicObfuscator, + FloatingRate, Packetizer, RateLimiter, ShapeEvent, @@ -91,6 +92,10 @@ def _positive_int(value, name): return parsed +def _env_default(name, fallback): + return os.environ.get(name, fallback) + + class PacketGenerator: """Packet generator with variable sizes and pseudo-random payload characteristics""" @@ -295,7 +300,6 @@ def __init__( if floating else target_mbps ) - self._last_rate_update = self._monotonic_clock() self.target_bytes_per_second = ( mbps_to_bytes_per_second(self.current_rate_mbps) if self.current_rate_mbps is not None @@ -306,13 +310,17 @@ def __init__( self.running = False self.packetizer = Packetizer(mtu, FRAME_OVERHEAD) self.data_payload_ceiling = self.packetizer.payload_ceiling - self.packet_gen = PacketGenerator( - max_size=min(1400, self.data_payload_ceiling), - rng=self._rng, - byte_source=self._byte_source, - ) - self.stats = {"bytes_sent": 0, "packets_sent": 0, "start_time": time.time()} - self.last_stats = {"bytes_sent": 0, "packets_sent": 0, "time": time.time()} + stats_started = self._monotonic_clock() + self.stats = { + "bytes_sent": 0, + "packets_sent": 0, + "start_time": stats_started, + } + self.last_stats = { + "bytes_sent": 0, + "packets_sent": 0, + "time": stats_started, + } self.stats_interval = stats_interval self.advanced = shape_mode == "profile" # Compatibility attribute. if shape_mode == "profile": @@ -339,8 +347,11 @@ def __init__( self._handshake_state_limit = ( max_clients + max_handshakes_per_second * cookie_ttl ) - self.obfuscator = None - self.generator = None + self.total_rate_limiter = RateLimiter( + mbps_to_bytes_per_second(max_total_mbps), + burst_bytes=self.mtu, + clock=self._monotonic_clock, + ) def start(self): """Start the server""" @@ -375,15 +386,6 @@ def start(self): else: print(f"[*] Target throughput: {self.target_mbps} Mbps", flush=True) if self.shape_mode == "profile": - self.obfuscator = DynamicObfuscator( - padding_strategy=self.padding_strategy, - timing_jitter=0.002, - mtu=self.data_payload_ceiling, - header_mode=self.header_mode, - rng=self._rng, - byte_source=self._byte_source, - ) - self.generator = profile_event_generator(self.profile, rng=self._rng) print( f"[*] Profile transform: header={self.header_mode}, " f"padding={self.padding_strategy}, mtu={self.mtu}", @@ -511,12 +513,9 @@ def _handle_auth(self, frame, addr, entry, now): self._prune_handshake_state(now) if replay_key in self._accepted_auth: return False - existing = 1 if addr in self.clients else 0 - prospective_clients = len(self.clients) - existing + 1 + prospective_clients = len(self.clients) - (1 if addr in self.clients else 0) + 1 if prospective_clients > self.max_clients: return False - if prospective_clients * self.configured_max_mbps > self.max_total_mbps: - return False if len(self._accepted_auth) >= self._handshake_state_limit: return False @@ -546,15 +545,54 @@ def _handle_auth(self, frame, addr, entry, now): self._accepted_auth[replay_key] = { "expires": now + self.cookie_ttl } + self.clients[addr] = self._new_client_state( + frame, + now, + receive_key, + send_key, + ) + print(f"[+] New client connected: {addr}", flush=True) + return True + + def _new_client_state(self, frame, now, receive_key, send_key): + seed_material = hashlib.sha256( + self.cookie_secret + frame.client_nonce + frame.session_nonce + ).digest() + client_rng = random.Random(int.from_bytes(seed_material, "big")) + floating_rate = None + current_rate_mbps = self.target_mbps + if self.shape_mode == "rate" and self.min_mbps is not None: + floating_rate = FloatingRate( + self.min_mbps, + self.max_mbps, + clock=self._monotonic_clock, + rng=client_rng, + ) + current_rate_mbps = floating_rate.value_mbps limiter_mbps = ( - self.current_rate_mbps - if self.shape_mode == "rate" - else self.max_mbps + current_rate_mbps if self.shape_mode == "rate" else self.max_mbps ) - self.clients[addr] = { + obfuscator = None + generator = None + if self.shape_mode == "profile": + obfuscator = DynamicObfuscator( + padding_strategy=self.padding_strategy, + timing_jitter=0.002, + mtu=self.data_payload_ceiling, + header_mode=self.header_mode, + rng=client_rng, + byte_source=self._byte_source, + ) + generator = profile_event_generator(self.profile, rng=client_rng) + + return { "last_seen": now, "bytes_received": 0, "packets_received": 0, + "bytes_sent": 0, + "packets_sent": 0, + "last_bytes_sent": 0, + "last_packets_sent": 0, "client_nonce": frame.client_nonce, "session_nonce": frame.session_nonce, "receive_key": receive_key, @@ -570,9 +608,21 @@ def _handle_auth(self, frame, addr, entry, now): if limiter_mbps is not None else None ), + "rng": client_rng, + "packet_gen": PacketGenerator( + max_size=min(1400, self.data_payload_ceiling), + rng=client_rng, + byte_source=self._byte_source, + ), + "floating_rate": floating_rate, + "current_rate_mbps": current_rate_mbps, + "generator": generator, + "obfuscator": obfuscator, + "pending_fragments": deque(), + "next_event_at": self._monotonic_clock(), + "pending_event_delay": 0.0, + "delay_after_send": None, } - print(f"[+] New client connected: {addr}", flush=True) - return True def _handle_session_frame(self, inspected, datagram, addr, now): client = self.clients.get(addr) @@ -644,74 +694,122 @@ def _frame_data_for_client(self, client, payload): ) def send_loop(self): - """Generate logical demand, packetize it, and pace framed datagrams.""" + """Serve one datagram per client per round under the aggregate cap.""" while self.running: - if not self.clients: + clients = list(self.clients.items()) + if not clients: self._sleep(0.1) continue - event = self._next_shape_event() - if event.byte_count: - payload = self._make_event_payload(event) - for fragment in self.packetizer.packetize(payload): - for addr, client in list(self.clients.items()): - self._send_fragment(addr, client, fragment) - if event.delay: - self._sleep(event.delay) + sent_any = False + next_ready_at = None + for addr, client in clients: + fragment = self._next_client_fragment(client) + if fragment is not None: + self._send_fragment(addr, client, fragment) + self._complete_client_fragment(client) + sent_any = True + elif client["next_event_at"] > self._monotonic_clock(): + next_ready_at = min( + client["next_event_at"], + next_ready_at or client["next_event_at"], + ) + if not sent_any: + delay = 0.01 + if next_ready_at is not None: + delay = min( + delay, + max(0.0, next_ready_at - self._monotonic_clock()), + ) + self._sleep(delay) + + def _next_client_fragment(self, client): + if client["pending_fragments"]: + fragment = client["pending_fragments"].popleft() + if not client["pending_fragments"]: + client["delay_after_send"] = client["pending_event_delay"] + return fragment + now = self._monotonic_clock() + if now < client["next_event_at"]: + return None + + event = self._next_shape_event(client) + if not event.byte_count: + client["next_event_at"] = now + event.delay + return None + payload = self._make_event_payload(client, event) + client["pending_fragments"].extend(self.packetizer.packetize(payload)) + if not client["pending_fragments"]: + return None + client["pending_event_delay"] = event.delay + fragment = client["pending_fragments"].popleft() + if not client["pending_fragments"]: + client["delay_after_send"] = event.delay + return fragment + + def _complete_client_fragment(self, client): + if client["delay_after_send"] is not None: + client["next_event_at"] = ( + self._monotonic_clock() + client["delay_after_send"] + ) + client["delay_after_send"] = None - def _next_shape_event(self): + def _next_shape_event(self, client): if self.shape_mode == "profile": - return next(self.generator) + return next(client["generator"]) - now = self._monotonic_clock() - if ( - self.min_mbps is not None - and now - self._last_rate_update >= 1.0 - ): - self.current_rate_mbps = self._rng.uniform( - self.min_mbps, self.max_mbps + if client["floating_rate"] is not None: + current_rate = client["floating_rate"].update() + client["current_rate_mbps"] = current_rate + client["rate_limiter"].set_rate( + mbps_to_bytes_per_second(current_rate) ) - self._last_rate_update = now - new_rate = mbps_to_bytes_per_second(self.current_rate_mbps) - self.target_bytes_per_second = new_rate - for client in self.clients.values(): - client["rate_limiter"].set_rate(new_rate) return ShapeEvent(byte_count=self.data_payload_ceiling) - def _make_event_payload(self, event): + def _make_event_payload(self, client, event): if self.shape_mode == "rate": - return self.packet_gen.generate_packet(event.byte_count) + return client["packet_gen"].generate_packet(event.byte_count) payload = bytes( generate_payload( event.byte_count, entropy=self.entropy, - rng=self._rng, + rng=client["rng"], byte_source=self._byte_source, ) ) if len(payload) != event.byte_count: raise ValueError("byte source returned the wrong event payload length") - return self.obfuscator.transform(payload, profile=self.profile) + return client["obfuscator"].transform(payload, profile=self.profile) def _send_fragment(self, addr, client, fragment): framed = self._frame_data_for_client(client, fragment) limiter = client["rate_limiter"] - reservation = limiter.reserve(len(framed)) if limiter else None - if reservation and reservation.delay: - self._sleep(reservation.delay) + client_reservation = limiter.reserve(len(framed)) if limiter else None + total_reservation = self.total_rate_limiter.reserve(len(framed)) + delay = max( + client_reservation.delay if client_reservation else 0.0, + total_reservation.delay, + ) + if delay: + self._sleep(delay) sent = 0 try: sent = self.socket.sendto(framed, addr) if sent == len(framed): self.stats["bytes_sent"] += sent self.stats["packets_sent"] += 1 + client["bytes_sent"] += sent + client["packets_sent"] += 1 else: sent = max(0, min(sent, len(framed))) except OSError as exc: print(f"[!] Send error to client {addr}: {exc}", flush=True) finally: - if reservation: - limiter.commit(reservation, successful_bytes=sent) + if client_reservation: + limiter.commit(client_reservation, successful_bytes=sent) + self.total_rate_limiter.commit( + total_reservation, successful_bytes=sent + ) def cleanup_loop(self): """Remove inactive clients""" @@ -730,10 +828,10 @@ def cleanup_loop(self): time.sleep(5) def stats_loop(self): - """Print runtime statistics""" + """Print total and per-client application-datagram egress rates.""" while self.running: - time.sleep(self.stats_interval) - now = time.time() + self._sleep(self.stats_interval) + now = self._monotonic_clock() # Calculate instantaneous rates based on delta since last stats time_delta = now - self.last_stats["time"] @@ -746,15 +844,35 @@ def stats_loop(self): pps = packets_delta / time_delta pattern_desc = ( - f"rate:{self.current_rate_mbps:.2f}Mbps" + "rate:per-client" + if self.shape_mode == "rate" and self.min_mbps is None + else f"floating:{self.min_mbps:.2f}-{self.max_mbps:.2f}Mbps" if self.shape_mode == "rate" else f"experimental-profile:{self.profile.value}" ) + client_rates = [] + for addr, client in self.clients.items(): + client_bytes = ( + client["bytes_sent"] - client["last_bytes_sent"] + ) + client_mbps = client_bytes * 8 / (time_delta * 1_000_000) + target = client["current_rate_mbps"] + target_text = ( + f",target={target:.2f}Mbps" + if target is not None + else ",native-profile" + ) + client_rates.append( + f"{addr[0]}:{addr[1]}={client_mbps:.2f}Mbps{target_text}" + ) + client["last_bytes_sent"] = client["bytes_sent"] + client["last_packets_sent"] = client["packets_sent"] + per_client = ";".join(client_rates) or "none" print( f"[STATS] Clients: {len(self.clients)} | " - f"Rate: {mbps:.2f} Mbps | " - f"PPS: {pps:.0f} | " - f"Pattern: {pattern_desc}", + f"Total Rate: {mbps:.2f} Mbps | " + f"Total PPS: {pps:.0f} | " + f"Per-client: {per_client} | Pattern: {pattern_desc}", flush=True, ) @@ -833,7 +951,7 @@ def main(): parser.add_argument( "--stats-interval", type=float, - default=5.0, + default=_env_default("TRAFFIC_MASKING_STATS_INTERVAL", 5.0), help="Stats print interval in seconds", ) auth_group = parser.add_mutually_exclusive_group() From 55c246ad3c3e96dd00ce28318a752a5bef872752 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:36:07 +0300 Subject: [PATCH 07/10] traffic-masking: remove unsupported enhanced features --- traffic-masking/AGENTS.md | 257 ++---- traffic-masking/Dockerfile | 1 - traffic-masking/EXAMPLES.md | 222 ++---- traffic-masking/Makefile | 6 +- traffic-masking/README.md | 198 +++-- traffic-masking/SUMMARY.md | 214 ++--- traffic-masking/enhanced/__init__.py | 25 - traffic-masking/enhanced/correlation.py | 419 ---------- traffic-masking/enhanced/entropy.py | 617 -------------- traffic-masking/enhanced/ml_resistance.py | 575 -------------- traffic-masking/enhanced/state_machine.py | 750 ------------------ traffic-masking/enhanced/timing.py | 311 -------- traffic-masking/masking_lib.py | 407 ++-------- traffic-masking/requirements.txt | 1 - traffic-masking/systemd/README.md | 161 ++-- .../systemd/traffic-masking-client.service | 6 +- .../systemd/traffic-masking-server.service | 2 - traffic-masking/test_cli.py | 9 - traffic-masking/test_control_protocol.py | 5 +- traffic-masking/test_core.py | 48 +- traffic-masking/test_imports.py | 35 +- traffic-masking/test_live.py | 2 +- traffic-masking/traffic_masking_client.py | 97 +-- traffic-masking/traffic_masking_server.py | 115 +-- 24 files changed, 462 insertions(+), 4021 deletions(-) delete mode 100644 traffic-masking/enhanced/__init__.py delete mode 100644 traffic-masking/enhanced/correlation.py delete mode 100644 traffic-masking/enhanced/entropy.py delete mode 100644 traffic-masking/enhanced/ml_resistance.py delete mode 100644 traffic-masking/enhanced/state_machine.py delete mode 100644 traffic-masking/enhanced/timing.py diff --git a/traffic-masking/AGENTS.md b/traffic-masking/AGENTS.md index 73a8164..daef45c 100644 --- a/traffic-masking/AGENTS.md +++ b/traffic-masking/AGENTS.md @@ -1,207 +1,50 @@ -# Traffic Masking: Architecture Review & Improvement Plan - -## Context - -The masking system runs as a parallel UDP stream alongside the main encrypted user tunnel inside a multiplexed encrypted transport layer. The external observer sees a single encrypted connection (e.g. QUIC on UDP 443) to a server with a valid TLS certificate. Both the user tunnel and the masking stream are multiplexed into this single connection and are indistinguishable at the packet level. - -## Threat Model - -The observer cannot read content but can analyze the **aggregate** encrypted stream: -- Throughput shape over time (volume per interval) -- Upload/download ratio -- Session duration and continuity -- Burst patterns and periodicity -- Idle/active transitions - -The goal is to make the aggregate stream look like **normal client behavior** (web browsing, media consumption, file downloads) regardless of what the user is actually doing inside the tunnel. - -## Current System Assessment - -### Architecture - -``` -Multiplexed encrypted transport: - ├── User tunnel (UDP) — real user traffic - └── Masking stream (UDP) — cover traffic from this system -``` - -The observer sees one encrypted connection. QUIC-level framing already blurs individual packet boundaries. The primary observable is **throughput shape** at second-scale granularity, not individual packet signatures. - -### What Works Well - -1. **Profile-based generation** (web_browsing, video_streaming, voip, gaming, mixed) — conceptually correct approach for this architecture -2. **Protocol mimicry with session lifecycle** — sessions with start/active/idle/end phases -3. **Floating rate with physics model** — momentum/velocity/acceleration produces organic-looking rate changes -4. **Markov chains per profile** — different statistical distributions per traffic type -5. **Session-level modeling** — not just constant noise, but structured sessions - -### What Needs Improvement - -The system has **zero awareness of actual user traffic**. The `send_loop()` and `receive_loop()` in the server are completely independent. The generator is self-contained — it does not observe, react to, or compensate for real tunnel traffic. - -This is the root cause of all issues below. - ---- - -## Problems To Solve - -### Problem 1: Aggregate Profile Can Be Implausible - -When the user tunnel carries steady bidirectional traffic and the masking system independently generates its own profile, the aggregate can look like **two overlapping sessions** — abnormal for a single client talking to one server. - -The masking system must **complement** the user traffic to form a plausible aggregate, not blindly add on top. - -### Problem 2: Upload/Download Ratio Leaks Activity Type - -Normal HTTPS clients are heavily download-dominant (~95%+ download). When the user tunnel generates significant upload (e.g. camera feed, file upload, interactive session), the aggregate upload ratio becomes abnormally high. - -Current `--response 0.3` (30% upload) **worsens** the problem by adding more upload. - -### Problem 3: No Idle Periods - -A real client has natural pauses — reading a page, between sessions, overnight. The masking system runs continuously, which is itself a detectable anomaly. No legitimate client generates traffic 24/7 without pauses. - -### Problem 4: Steady Throughput Is a Signature - -Constant bitrate or predictably oscillating throughput (sine waves, random walks within fixed bounds) is **more suspicious** than what it tries to hide. Real HTTPS traffic is bursty and irregular. - -### Problem 5: Burst Events Not Compensated - -Periodic burst patterns from user traffic (e.g. keyframe bursts from video codecs every 1-2 sec) are visible in the aggregate throughput. The masking system cannot counteract them because it doesn't know they're happening. - ---- - -## Implementation Plan - -### Phase 1: Traffic-Aware Adaptive Mode - -**Goal:** The masking system monitors the user tunnel interface and adapts its output to maintain a plausible aggregate profile. - -#### 1.1 Tunnel Traffic Monitor - -- Poll user tunnel interface statistics (`/sys/class/net//statistics/` or `ip -s link`) at ~50-100ms intervals -- Track instantaneous throughput (tx/rx bytes), upload/download ratio, burstiness -- Expose as a shared data structure for the generator to consume - -#### 1.2 Activity Classifier - -Based on observed tunnel metrics, classify current user activity into categories: - -| Observed Pattern | Classification | Masking Strategy | -|---|---|---| -| High steady bidirectional | Interactive/streaming session | Disguise as media consumption — high download bursts (buffering), suppress masking upload | -| Low bursty, download-dominant | Web browsing | Light masking or none — traffic already looks normal | -| High download, low upload | File download / streaming | Minimal masking — already plausible | -| Near-silent | Idle | Generate idle-appropriate background (keepalives, rare small bursts) | -| High upload, low download | Upload session | Disguise as form submission / file upload — add compensating download | - -#### 1.3 Aggregate-Aware Rate Control - -Replace the current self-contained floating rate with: - -``` -target_profile = classify(tunnel_throughput) -masking_rate = compute_complement(tunnel_throughput, target_profile) -``` - -The masking rate is the **difference** between the desired aggregate profile and the actual tunnel throughput, not an independent value. - -### Phase 2: Upload Suppression & Ratio Control - -**Goal:** Keep aggregate upload/download ratio within normal HTTPS bounds (3-8% upload). - -- Calculate real-time aggregate ratio: `(tunnel_upload + masking_upload) / (tunnel_download + masking_download)` -- If ratio > threshold: reduce masking upload, increase masking download -- If ratio < threshold: slightly increase masking upload (rare — most scenarios are upload-heavy) -- Inverse response ratio: when tunnel upload is high, masking upload should be near-zero - -### Phase 3: Realistic Session Modeling - -**Goal:** The aggregate stream should have human-like activity patterns with natural idle periods. - -#### 3.1 Session Scheduler - -- Generate realistic "browsing sessions" (5-30 min active, 1-10 min idle) -- During idle periods: only keepalives and minimal background traffic -- **Critical constraint:** If user tunnel is active during a scheduled idle period, generate enough "background" traffic to prevent tunnel traffic from being exposed as the only activity. The "idle" must look like the client is still connected but doing light background work. - -#### 3.2 Diurnal Patterns - -- Optional time-of-day awareness: less traffic at night, peaks during day -- Configurable timezone and activity profile -- Long idle periods (sleep hours) where only keepalives flow - -#### 3.3 Session Transitions - -- Smooth transitions between activity levels (not instant jumps) -- Realistic ramp-up (page load burst → steady reading) and ramp-down (gradual disengagement) - -### Phase 4: Aggregate Validation - -**Goal:** Continuously validate that the aggregate stream matches expected statistical properties of legitimate traffic. - -#### 4.1 Aggregate Statistics Collector - -- Compute sliding-window statistics on the **aggregate** (tunnel + masking): - - Throughput mean, variance, autocorrelation (1-sec, 10-sec, 60-sec windows) - - Upload/download ratio - - Burst frequency and amplitude - - Idle period distribution - -#### 4.2 Profile Comparator - -- Compare aggregate statistics against reference profiles of legitimate traffic -- If deviation exceeds threshold: adjust masking parameters in real-time -- Reference profiles should be derived from real traffic captures (web browsing, video streaming, file downloads) - -#### 4.3 ML Resistance on Aggregate (not individual packets) - -- Move the existing ML resistance logic from per-packet to per-aggregate level -- The adversarial features should target aggregate throughput shape, not individual packet sizes -- Since the encrypted transport already blurs packet-level features, focus ML resistance entirely on volume/timing analysis - -### Phase 5: Anti-Burst Compensation - -**Goal:** Smooth out periodic burst patterns from user traffic that leak through to the aggregate. - -- When tunnel throughput spikes: optionally reduce masking rate slightly (so aggregate spike is dampened) -- When tunnel throughput dips: increase masking to fill the gap -- This is NOT constant bitrate — the aggregate still varies, but the **variance introduced by user traffic** is partially absorbed -- Tunable aggressiveness: full compensation (more constant, slightly suspicious) vs. partial compensation (more natural, less protection) - ---- - -## Implementation Notes - -### Interface Monitoring - -The tunnel interface name should be configurable (`--tunnel-iface`). Polling via sysfs is lightweight (~0 CPU cost). Fallback to `psutil` or socket-level monitoring if sysfs is unavailable. - -### Backward Compatibility - -- Current standalone mode (no tunnel awareness) should remain as `--mode standalone` -- New adaptive mode activated via `--mode adaptive --tunnel-iface ` -- All existing profiles and generation logic are reused as building blocks - -### Performance Considerations - -- Tunnel monitoring at 50-100ms granularity adds negligible overhead -- Activity classification should be lightweight (threshold-based, not ML) -- Aggregate validation can run at 1-sec intervals (not per-packet) - -### Testing Strategy - -- Unit tests: activity classifier with synthetic throughput traces -- Integration tests: run masking alongside simulated tunnel traffic, validate aggregate statistics -- Capture real traffic profiles (web browsing, streaming) as reference baselines for validation -- Compare aggregate statistical fingerprint with and without masking enabled - ---- - -## Priority Order - -1. **Phase 1** (Traffic-Aware Mode) — without this, everything else is cosmetic -2. **Phase 2** (Upload Suppression) — most detectable anomaly after awareness -3. **Phase 3** (Session Modeling) — prevents long-running constant-traffic detection -4. **Phase 5** (Anti-Burst) — smooths out the most obvious leaks -5. **Phase 4** (Aggregate Validation) — continuous quality assurance layer +# Traffic Masking Repository Guide + +## Architecture + +This project is an experimental authenticated UDP cover-traffic generator. The +server and client are expected to run beside a user tunnel and have both streams +multiplexed into the same external encrypted transport. + +The enclosing multiplexer is not part of this repository. Direct UDP execution +is useful for tests and diagnostics but exposes a separate flow and plaintext +application framing. + +## Current Contracts + +- `rate` mode targets fixed or bounded floating framed-byte rates per validated + client. +- `profile` mode preserves native handcrafted event sizes and gaps; an optional + maximum is a ceiling only. +- A `ShapeEvent` becomes padded logical bytes, then `Packetizer` fragments it, + then rate limiters account successfully submitted framed datagrams. +- Each validated client owns independent generator, RNG, pacing, and counters. +- The server-wide limiter caps aggregate egress with round-robin service. +- Client response accounting includes DATA framing, padding, and keepalives. +- Production enrollment requires a restrictive PSK file. Diagnostic mode is + explicit and uses no secret. + +## Claims Boundary + +The traffic profiles are handcrafted and are not reference-backed models. Do not +describe them as statistically indistinguishable from normal traffic or as proven +resistance to traffic analysis. + +Payload contents are opaque cover bytes intended for an encrypted outer +transport. Internal plaintext formats are not useful evidence about the observer +boundary. Only transformations with a defined effect on submitted byte volume or +timing belong in the runtime path. + +Cover traffic can fill a volume deficit. It cannot remove user bytes, cancel a +spike, or guarantee a target aggregate when user traffic already exceeds it. + +## Development + +- Keep rate units as decimal Mbit/s of successfully submitted framed application + datagrams. +- Keep stochastic tests deterministic through injected clocks and RNGs. +- Preserve the PSK, anti-amplification, sequence, MTU, per-client, and aggregate + cap tests when changing the data path. +- Run `make test-fast`, `make lint`, and `make test-live` for changes that affect + process or network behavior. +- Runtime code must remain importable with only the standard library. diff --git a/traffic-masking/Dockerfile b/traffic-masking/Dockerfile index 1fb7676..2556793 100644 --- a/traffic-masking/Dockerfile +++ b/traffic-masking/Dockerfile @@ -25,7 +25,6 @@ RUN pip install --no-cache-dir -r requirements.txt # Copy application files COPY control_protocol.py masking_lib.py traffic_masking_server.py traffic_masking_client.py /app/ -COPY enhanced/ /app/enhanced/ # Copy documentation COPY *.md /app/ diff --git a/traffic-masking/EXAMPLES.md b/traffic-masking/EXAMPLES.md index b3770fe..f7f2c91 100644 --- a/traffic-masking/EXAMPLES.md +++ b/traffic-masking/EXAMPLES.md @@ -1,211 +1,117 @@ -# Traffic Masking System - Examples +# Traffic Masking Examples -## Quick Start +All examples require the same restrictive PSK file on the server and client: ```bash umask 077 openssl rand 32 > traffic-masking.psk +``` -# Test the system without live process/network tests -make test-fast +Run these UDP processes only inside the intended external encrypted transport. +Direct raw-UDP use is suitable for local diagnostics, not confidentiality. -# Basic server and client -python traffic_masking_server.py --mbps 5 --psk-file ./traffic-masking.psk -python traffic_masking_client.py --server 127.0.0.1 --psk-file ./traffic-masking.psk +## Fixed Rate -# Floating rate -python traffic_masking_server.py --shape-mode rate --min-mbps 2 --max-mbps 8 \ +```bash +python traffic_masking_server.py \ + --host 0.0.0.0 --port 8888 \ + --shape-mode rate --mbps 5 \ + --max-clients 4 --max-total-mbps 20 \ --psk-file ./traffic-masking.psk -python traffic_masking_client.py --server 127.0.0.1 --response 0.3 --advanced \ + +python traffic_masking_client.py \ + --server SERVER_IP --port 8888 \ --psk-file ./traffic-masking.psk ``` -Mbps values are decimal Mbit/s of application UDP payload. The client defaults -to no scheduled uplink (`--response 0.0`). A nonzero response is an explicit -diagnostic/profile choice. Its ratio covers successfully submitted DATA, -framing, padding and keepalive bytes relative to authenticated downlink datagram -bytes; mandatory keepalives can temporarily exceed the target and are repaid by -pausing DATA. - -Server `rate` mode supplies demand to reach its configured target. Experimental -`profile` mode preserves native event sizes and gaps; `--max-mbps` only caps it. -Rates are per validated client. `--max-total-mbps` is an actual aggregate cap; -when it binds, validated clients share it in round-robin order. +The 5 Mbps target is per validated client. The server-wide cap is 20 Mbps. -Client health and reporting timings support CLI flags and environment defaults: +## Floating Rate ```bash -TRAFFIC_MASKING_KEEPALIVE_INTERVAL=2 \ -TRAFFIC_MASKING_RECEIVE_TIMEOUT=8 \ -TRAFFIC_MASKING_RECONNECT_DELAY_MIN=0.5 \ -TRAFFIC_MASKING_RECONNECT_DELAY_MAX=10 \ -TRAFFIC_MASKING_STATS_INTERVAL=2 \ -python traffic_masking_client.py --server SERVER_IP \ +python traffic_masking_server.py \ + --shape-mode rate --min-mbps 2 --max-mbps 8 \ + --max-total-mbps 16 \ --psk-file ./traffic-masking.psk ``` -`TRAFFIC_MASKING_KEEPALIVE_JITTER` sets the fractional jitter (default `0.2`). -Equivalent CLI flags override these defaults. The receive timeout must be -greater than `keepalive interval * (1 + jitter)`. - -## Use Cases +Each client receives an independent bounded rate sequence. -### Mask Video Calls -```bash -# Google Meet / Zoom / Teams -python traffic_masking_server.py --shape-mode profile --max-mbps 5 \ - --profile video --psk-file ./traffic-masking.psk - -# WhatsApp / Telegram voice calls -python traffic_masking_server.py --shape-mode profile --max-mbps 1.5 \ - --profile voip --psk-file ./traffic-masking.psk -``` +## Native Profile With Padding -### Mask Web Browsing ```bash -python traffic_masking_server.py --shape-mode profile --max-mbps 4 \ - --profile web --header quic --psk-file ./traffic-masking.psk -``` - -### Maximum Security Configuration -```bash -# Server python traffic_masking_server.py \ - --shape-mode profile --max-mbps 10 \ - --profile mixed \ - --header rtp --padding random \ - --entropy 1.0 --psk-file ./traffic-masking.psk + --shape-mode profile --profile web --max-mbps 4 \ + --padding fixed_buckets \ + --psk-file ./traffic-masking.psk -# Client python traffic_masking_client.py \ - --server SERVER_IP --response 0.4 \ - --advanced --uplink-profile mixed \ - --header rtp --padding random \ + --server SERVER_IP --response 0.05 --padding random \ --psk-file ./traffic-masking.psk ``` -### Performance Optimized +Profile timings and event sizes are experimental. The cap only delays offered +load above 4 Mbps; it does not force the profile to reach 4 Mbps. + +## Local Diagnostic + ```bash -# Lower CPU usage, good throughput python traffic_masking_server.py \ - --shape-mode profile --max-mbps 8 --profile web \ - --header none --padding none --entropy 0.7 \ - --psk-file ./traffic-masking.psk -``` - -## Integration + --host 127.0.0.1 --mbps 1 --insecure-diagnostic -### WireGuard -```ini -# /etc/wireguard/wg0.conf -[Interface] -PostUp = systemctl start traffic-masking-server -PreDown = systemctl stop traffic-masking-server +python traffic_masking_client.py \ + --server 127.0.0.1 --insecure-diagnostic ``` -### Docker -```bash -# Build and run -docker build -t traffic-masking . +The diagnostic mode uses no secret. Keep it on an isolated local interface. -# Server -docker run -d --name tm-server --network host \ - --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_server.py --shape-mode rate \ - --min-mbps 2 --max-mbps 8 --psk-file /run/secrets/traffic-masking.psk +## Short Timing Values For Testing -# Client -docker run -d --name tm-client --network host \ - --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_client.py --server SERVER_IP --response 0.3 \ - --advanced --psk-file /run/secrets/traffic-masking.psk +```bash +python traffic_masking_client.py \ + --server 127.0.0.1 \ + --keepalive-interval 0.5 \ + --keepalive-jitter 0 \ + --receive-timeout 2 \ + --reconnect-delay-min 0.2 \ + --reconnect-delay-max 1 \ + --psk-file ./traffic-masking.psk ``` -### Docker Compose -```yaml -version: '3.8' -services: - server: - build: . - network_mode: host - command: traffic_masking_server.py --shape-mode rate --min-mbps 2 --max-mbps 8 --psk-file /run/secrets/traffic-masking.psk - volumes: - - ./traffic-masking.psk:/run/secrets/traffic-masking.psk:ro - restart: unless-stopped - - client: - build: . - network_mode: host - command: traffic_masking_client.py --server ${SERVER_IP} --response 0.3 --advanced --psk-file /run/secrets/traffic-masking.psk - volumes: - - ./traffic-masking.psk:/run/secrets/traffic-masking.psk:ro - restart: unless-stopped - depends_on: - - server -``` +The receive timeout must exceed the maximum jittered keepalive interval. -## Performance Tuning +## Environment Defaults -### Network Buffers (Linux) ```bash -# Temporary -sudo sysctl -w net.core.rmem_max=134217728 -sudo sysctl -w net.core.wmem_max=134217728 - -# Permanent -echo "net.core.rmem_max=134217728" | sudo tee -a /etc/sysctl.conf -echo "net.core.wmem_max=134217728" | sudo tee -a /etc/sysctl.conf -sudo sysctl -p +TRAFFIC_MASKING_KEEPALIVE_INTERVAL=2 \ +TRAFFIC_MASKING_RECEIVE_TIMEOUT=8 \ +TRAFFIC_MASKING_RECONNECT_DELAY_MIN=0.5 \ +TRAFFIC_MASKING_RECONNECT_DELAY_MAX=10 \ +TRAFFIC_MASKING_STATS_INTERVAL=2 \ +python traffic_masking_client.py \ + --server SERVER_IP --psk-file ./traffic-masking.psk ``` -### Process Priority -```bash -# High priority -sudo nice -n -10 python traffic_masking_server.py --shape-mode rate \ - --min-mbps 5 --max-mbps 15 --psk-file ./traffic-masking.psk +Set `TRAFFIC_MASKING_KEEPALIVE_JITTER` to override the default fractional jitter +of `0.2`. -# CPU affinity (cores 0,1) -taskset -c 0,1 python traffic_masking_server.py --shape-mode rate \ - --min-mbps 5 --max-mbps 15 --psk-file ./traffic-masking.psk -``` +## Docker -### PyPy for Better Performance ```bash -sudo apt-get install pypy3 -pypy3 -m pip install numpy -pypy3 traffic_masking_server.py --shape-mode rate --min-mbps 5 --max-mbps 15 \ - --psk-file ./traffic-masking.psk +docker build -t traffic-masking . + +docker run --network host \ + --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ + traffic-masking traffic_masking_server.py \ + --shape-mode profile --profile mixed --max-mbps 8 --padding random \ + --psk-file /run/secrets/traffic-masking.psk ``` ## Monitoring ```bash -# Traffic analysis sudo tcpdump -i any -n udp port 8888 -c 100 -sudo iftop -i eth0 -f "udp port 8888" - -# Process monitoring -htop -pidstat -p $(pgrep -f traffic_masking_server) 1 - -# Total and per-client rates are labelled separately grep "Total Rate:" server.log grep "Per-client:" server.log -``` - -## Troubleshooting - -```bash -# Test authenticated connectivity -python traffic_masking_client.py --server SERVER_IP \ - --psk-file ./traffic-masking.psk --stats-interval 1 - -# Debug mode with verbose output -PYTHONUNBUFFERED=1 python -u traffic_masking_server.py \ - --shape-mode rate --mbps 5 --stats-interval 1 \ - --psk-file ./traffic-masking.psk 2>&1 | tee server.log - -# Network statistics -netstat -su | grep -A 5 Udp: -ss -u -a -n | grep 8888 +grep "Uplink ratio:" client.log ``` diff --git a/traffic-masking/Makefile b/traffic-masking/Makefile index 300f9e4..8baf6f9 100644 --- a/traffic-masking/Makefile +++ b/traffic-masking/Makefile @@ -9,7 +9,7 @@ DEPS_STAMP := $(VENV)/.deps-installed COV := --cov=control_protocol --cov=masking_lib \ --cov=traffic_masking_server --cov=traffic_masking_client \ - --cov=enhanced --cov-branch --cov-report=term-missing + --cov-branch --cov-report=term-missing .PHONY: venv test test-fast test-live lint run-server run-client clean @@ -39,8 +39,8 @@ run-server: venv ## demo server (experimental mixed profile, capped at $(PYTHON) traffic_masking_server.py --shape-mode profile --profile mixed --max-mbps 8 --insecure-diagnostic run-client: venv ## demo client against 127.0.0.1 - $(PYTHON) traffic_masking_client.py --server 127.0.0.1 --advanced --uplink-profile mixed --insecure-diagnostic + $(PYTHON) traffic_masking_client.py --server 127.0.0.1 --insecure-diagnostic clean: ## remove venv and generated artifacts - rm -rf $(VENV) __pycache__ enhanced/__pycache__ \ + rm -rf $(VENV) __pycache__ \ .pytest_cache .ruff_cache .coverage htmlcov *.log diff --git a/traffic-masking/README.md b/traffic-masking/README.md index 13f26b1..9cd5fbb 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -1,143 +1,135 @@ -# Traffic Masking System - -UDP-based cover traffic generator designed to mask traffic patterns inside encrypted tunnels and defeat traffic analysis including ML-based detection. - -## Features - -- **Dynamic traffic patterns**: CBR, burst, wave, random walk, media-like -- **Floating rate mode**: Smooth traffic variations between min/max bounds -- **High throughput**: 8-10 Mbps sustained rate -- **ML resistance**: Advanced obfuscation techniques -- **Protocol mimicry**: 6 traffic profiles (web, video, voip, file, gaming, mixed) -- **Bidirectional flow**: Adaptive client uplink response -- **Auto-reconnection**: Client recovers automatically after server restart or network loss -- **Authenticated enrollment**: Source-bound challenge cookies and HMAC-SHA256 - session framing prevent unauthenticated cover-traffic amplification +# Traffic Masking + +Experimental authenticated UDP cover-traffic generator. The server emits a +controlled stream to validated clients; clients may return a configured fraction +of the received volume. + +This repository does not implement an encrypted tunnel or multiplexer. A real +deployment must place both user traffic and this cover stream inside the same +external encrypted transport. Running the programs directly over the Internet +creates a separate, identifiable UDP flow and provides no payload confidentiality. + +The handcrafted profiles have not been validated against reference captures. +This project does not claim statistical indistinguishability from legitimate +traffic or resistance to traffic analysis. + +## Runtime Contracts + +- Production enrollment uses a shared PSK, source-bound challenge cookies, + authenticated session framing, and monotonic sequence numbers. +- Decimal Mbps means framed application UDP bytes successfully submitted by the + process, converted with `1 Mbps = 1,000,000 bit/s`. +- Configured rates and profile caps are per validated client. +- `--max-total-mbps` applies a round-robin aggregate server egress cap. +- `--mtu` is the final application datagram ceiling after authenticated framing. + IP, UDP, and enclosing transport overhead are outside this value. +- Payload padding adds observable volume before packetization. It is not a claim + about the plaintext format inside the encrypted transport. ## Installation +The runtime uses only the Python standard library. + ```bash python3 -m venv venv -source venv/bin/activate # Linux/macOS +source venv/bin/activate pip install -r requirements.txt ``` -## Quick Start - -Create one binary PSK and install the same file on both endpoints. Keep the file -out of source control and do not pass the key value on the command line. +Create one binary PSK and install the same file on both endpoints: ```bash umask 077 openssl rand 32 > traffic-masking.psk ``` -### Basic Usage +The key must contain 32-4096 bytes and must not grant group or other access. + +## Rate Mode + +Rate mode supplies enough demand to target either a fixed per-client rate or a +bounded floating rate. ```bash -# Server with fixed rate -python traffic_masking_server.py --mbps 5 --psk-file ./traffic-masking.psk +# Fixed 5 Mbps per validated client +python traffic_masking_server.py \ + --shape-mode rate --mbps 5 \ + --psk-file ./traffic-masking.psk -# Server with floating rate (recommended) -python traffic_masking_server.py --min-mbps 2 --max-mbps 8 \ +# Smooth bounded rate process between 2 and 8 Mbps per client +python traffic_masking_server.py \ + --shape-mode rate --min-mbps 2 --max-mbps 8 \ + --max-total-mbps 20 \ --psk-file ./traffic-masking.psk -# Client -python traffic_masking_client.py --server \ +python traffic_masking_client.py \ + --server SERVER_IP \ --psk-file ./traffic-masking.psk ``` -### Experimental Profile Mode +## Profile Mode -Profile mode preserves each handcrafted profile's native event volumes and -gaps. `--max-mbps` is only a ceiling; it does not raise a low-rate profile to -the cap. These profiles remain experimental pending reference-trace validation. +Profile mode preserves the native logical sizes and gaps of a selected +handcrafted profile. `--max-mbps` is an optional ceiling; it does not raise a +profile's offered load to that value. ```bash -# Server with native mixed-profile load and a 10 Mbps ceiling python traffic_masking_server.py \ - --shape-mode profile --profile mixed --max-mbps 10 \ - --header rtp --padding random \ + --shape-mode profile --profile mixed --max-mbps 8 \ + --padding random \ --psk-file ./traffic-masking.psk -# Client with matching configuration -# A nonzero response is an explicit diagnostic/profile uplink choice. python traffic_masking_client.py \ - --server --response 0.3 \ - --advanced --uplink-profile mixed \ + --server SERVER_IP --response 0.05 --padding random \ --psk-file ./traffic-masking.psk ``` +Available profiles are `web`, `video`, `voip`, `file`, `gaming`, and `mixed`. +Available padding strategies are `none`, `random`, `fixed_buckets`, and +`progressive`. + +## Uplink Accounting + +`--response` is the requested ratio of successfully submitted framed uplink +bytes to authenticated downlink datagram bytes. DATA framing, payload padding, +and keepalives share one budget. Mandatory keepalives may create temporary debt; +scheduled DATA pauses until received volume repays it. The default is `0.0`. + +## Authentication + +`--psk-file` is required on both endpoints. Missing, unreadable, short, large, or +permissively-mode files fail closed. The PSK is used for authentication, not +payload encryption; confidentiality still depends on the external transport. + +`--insecure-diagnostic` uses a public built-in key. It is intended only for +isolated local diagnostics and remains subject to handshake, client, and rate +limits. + +## Timing And Metrics + +Client health timings are configurable with: + +- `--keepalive-interval` / `TRAFFIC_MASKING_KEEPALIVE_INTERVAL` +- `--keepalive-jitter` / `TRAFFIC_MASKING_KEEPALIVE_JITTER` +- `--receive-timeout` / `TRAFFIC_MASKING_RECEIVE_TIMEOUT` +- `--reconnect-delay-min` / `TRAFFIC_MASKING_RECONNECT_DELAY_MIN` +- `--reconnect-delay-max` / `TRAFFIC_MASKING_RECONNECT_DELAY_MAX` + +`--stats-interval` or `TRAFFIC_MASKING_STATS_INTERVAL` controls reporting on +either endpoint. CLI values override environment defaults. Server logs label +total and per-client rates separately. + ## Testing ```bash -# Run fast unit tests make test-fast - -# Run bounded live process/network tests +make lint make test-live - -# Run the complete pytest suite make test ``` -## Key Parameters - -- `--shape-mode rate|profile`: Select an explicit offered-load contract. The - default is `rate`. -- `--mbps`: Fixed target in decimal Mbps of authenticated application datagram - bytes for rate mode (default 5) -- `--min-mbps/--max-mbps`: Floating range in rate mode -- `--profile`: Required experimental pattern in profile mode -- `--max-mbps`: In profile mode, an optional ceiling that only adds delay -- `--advanced`: Deprecated warning-emitting alias for profile mode -- `--response`: Optional diagnostic/profile uplink setting (0.0-1.0, default - 0.0). The client budgets successfully submitted framed uplink bytes as this - fraction of authenticated downlink datagram bytes. DATA, framing, padding and - keepalives share the budget; mandatory keepalives can create temporary debt. -- `--header`: Pseudo-headers (none/rtp/quic) -- `--padding`: Padding strategy (none/random/fixed_buckets/progressive) -- `--entropy`: Payload entropy (0.0-1.0) -- `--mtu`: Maximum application UDP datagram size after protocol framing and - padding. This is application packetization, not IP fragmentation; account for - IP and outer encrypted-transport overhead when selecting a path-safe value. -- `--psk-file`: Path to the shared 32-4096 byte binary key. The file must not - grant group or other permissions. -- `--max-clients`, `--max-total-mbps`: Bound authenticated enrollment and actual - aggregate server egress. The configured rate is per client; a round-robin - global limiter shares a binding total cap between validated clients. -- `--max-handshakes-per-second`: Bound global handshake processing. Pending and - replay state expires with the cookie window; full state refuses new enrollment - rather than evicting an authenticated client. -- `--keepalive-interval`, `--keepalive-jitter`, `--receive-timeout`: Control - client health checks. The receive timeout must exceed the maximum jittered - keepalive interval. -- `--reconnect-delay-min`, `--reconnect-delay-max`: Bound exponential reconnect - backoff. -- `--stats-interval`: Controls reporting on both endpoints. Server reports - explicitly labelled total and per-client framed application-datagram rates. - -Client timing defaults can also be set with -`TRAFFIC_MASKING_KEEPALIVE_INTERVAL`, `TRAFFIC_MASKING_KEEPALIVE_JITTER`, -`TRAFFIC_MASKING_RECEIVE_TIMEOUT`, `TRAFFIC_MASKING_RECONNECT_DELAY_MIN`, and -`TRAFFIC_MASKING_RECONNECT_DELAY_MAX`. `TRAFFIC_MASKING_STATS_INTERVAL` applies -to either endpoint. CLI values override environment defaults. - -`--insecure-diagnostic` uses a public built-in key and is only for local -diagnostics. Production startup fails closed when the PSK is missing, -unreadable, too short, too large, or has permissive file modes. - -## Key Rotation - -There is no multi-key grace period. Generate a replacement file with mode -`0600`, stop both endpoints, atomically replace the old file on both hosts, and -restart both processes. Never log the key or put its value in a service command. - -## Documentation - -- [Examples](EXAMPLES.md) - Usage examples and deployment scenarios -- [Technical Summary](SUMMARY.md) - Implementation details and architecture -- [Changelog](CHANGELOG.md) - Version history +The live suite starts real loopback server/client processes and requires local +UDP sockets and process creation. ## Docker @@ -145,8 +137,8 @@ restart both processes. Never log the key or put its value in a service command. docker build -t traffic-masking . docker run --network host \ --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_server.py --shape-mode rate \ - --min-mbps 2 --max-mbps 8 \ + traffic-masking traffic_masking_server.py \ + --shape-mode rate --min-mbps 2 --max-mbps 8 \ --psk-file /run/secrets/traffic-masking.psk ``` @@ -155,7 +147,7 @@ The mounted secret must be readable by container UID 1000 while retaining mode ## Systemd -See [systemd/](systemd/) directory for service unit files. +See [systemd/](systemd/) for unit templates and installation notes. ## License diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index 34a90b6..d48a893 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -1,140 +1,74 @@ -# Traffic Masking System - Technical Summary - -## Architecture - -### Core Components - -**masking_lib.py** -- `stream_generator()`: Main traffic generation with fixed/floating rate support -- `DynamicObfuscator`: Packet obfuscation and fragmentation -- `ShapeEvent`, `Packetizer`, `RateLimiter`: Explicit offered-load, - application-packetization, and framed-byte pacing contracts -- `FloatingRate`: Bounded slope-limited per-client rate state -- `RatioBudget`: Successful framed uplink accounting against downlink bytes -- `ProtocolMimicry`: Pattern generation for different traffic profiles -- `TrafficProfile`: Enum for supported profiles (web, video, voip, file, gaming, mixed) - -**control_protocol.py** -- Versioned binary envelope for control and data datagrams -- HMAC-SHA256 authentication with direction-specific session keys and monotonic - sequences -- Stateless, source-bound challenge cookies with bounded pre-validation replies -- Restrictive PSK file validation - -**traffic_masking_server.py** -- Multi-client UDP server with independent generator, limiter, RNG and counters -- Authenticated client enrollment with client and handshake-rate caps -- Round-robin per-client pacing under an actual aggregate egress limiter -- Explicit fixed/floating rate mode and experimental native profile mode -- Real-time statistics monitoring - -**traffic_masking_client.py** -- Authenticated challenge/response handshake and source validation -- Monotonic receive-rate windows and configurable health/reconnect timings -- Response ratio control over DATA, framing, padding and control bytes - -### Enhanced Modules (optional) -- `enhanced/timing.py`: Adaptive timing with congestion modeling -- `enhanced/correlation.py`: Markov chain-based size generation -- `enhanced/ml_resistance.py`: Adversarial packet generation -- `enhanced/entropy.py`: Realistic encrypted payload simulation -- `enhanced/state_machine.py`: Protocol state machines (TLS, QUIC, WebRTC, SSH, HTTP/2) - -## Key Algorithms - -### Floating Rate Algorithm -```python -# Low-pass random slope with midpoint reversion -desired_slope = midpoint_force + bounded_noise -slope += (desired_slope - slope) * elapsed / response_time -slope = clamp(slope, -max_slope, max_slope) -current_mbps += slope * elapsed - -# Soft reflection avoids exact-boundary dwell -current_mbps = reflect_inside(current_mbps, min_mbps, max_mbps) -``` - -- Monotonic-clock updates with injected RNG for deterministic tests -- Bounded derivative and nonzero long-run variance -- Independent state and sequence for every validated client - -### Performance Optimizations -- Batch processing: 10 packets per batch -- Selective enhancement: 10% of large packets use advanced features -- Adaptive delay adjustment based on actual vs target rate -- Socket buffer optimization (4MB send/receive) - -## Traffic Profiles - -| Profile | Characteristics | Use Case | -|---------|----------------|----------| -| web | Bursty with idle periods | HTTP/HTTPS browsing | -| video | Steady high rate with buffering | Streaming services | -| voip | Low steady rate, bidirectional | Voice calls | -| file | Maximum throughput bursts | Downloads/uploads | -| gaming | Low latency, small packets | Real-time games | -| mixed | Combination of patterns | General purpose | - -## Obfuscation Techniques - -1. **Padding Strategies** - - Random: Variable padding 0-MTU - - Fixed buckets: Quantized sizes (64, 128, 256, 512, 1024) - - Progressive: Gradually increasing sizes - -2. **Pseudo-headers** - - RTP-like: 12-byte header mimicking RTP - - QUIC-like: Variable header mimicking QUIC - -3. **Entropy Control** - - Adjustable payload randomness (0.0-1.0) - - Pattern injection for protocol mimicry - -## Performance Characteristics - -- **Throughput**: 8-10 Mbps sustained -- **Efficiency**: 100-125% of target rate -- **Latency**: <1ms added delay in basic mode -- **CPU**: ~45% single core at 8 Mbps -- **Memory**: ~50MB typical usage - -## Deployment Modes - -### Basic Mode -- Simple traffic generation -- Minimal CPU usage -- No enhanced features - -### Advanced Mode -- Full obfuscation stack -- ML resistance features -- Protocol state tracking -- Higher CPU usage - -## Security Analysis - -### Attack Resistance -- **Statistical Analysis**: Correlation breaking via Markov chains -- **Machine Learning**: Adversarial generation patterns -- **Timing Analysis**: Adaptive jitter and delays -- **Size Analysis**: Dynamic size distributions -- **Protocol Analysis**: State machine simulation - -### Limitations -- No encryption (requires encrypted tunnel) -- Single-threaded (Python GIL) -- Detectable as cover traffic under deep inspection - -## Integration Points - -- **VPN**: PostUp/PreDown hooks -- **Docker**: Network host mode required for UDP -- **Systemd**: Service units for automatic startup -- **Monitoring**: Real-time statistics via stdout - -## Future Improvements - -- Multi-threading support -- Rust/C++ performance modules -- Distributed operation mode -- Cross-layer coordination with VPN +# Traffic Masking Technical Summary + +## Scope + +The project generates an authenticated UDP cover stream. It does not implement +the encrypted transport that must multiplex cover bytes with user traffic. Raw +UDP output is separately observable and unencrypted. + +No reference dataset currently establishes that the generated aggregate is +statistically indistinguishable from legitimate traffic. + +## Components + +`control_protocol.py` provides: + +- versioned, length-checked control and DATA framing; +- HMAC-SHA256 authentication and direction-specific session keys; +- source-bound challenge cookies and bounded pre-validation replies; +- monotonic sequence validation and restrictive PSK loading. + +`masking_lib.py` provides: + +- `ShapeEvent` for logical volume and intended gaps; +- `Packetizer` for final framed application datagram ceilings; +- `RateLimiter` for reservation/commit accounting of submitted bytes; +- `FloatingRate` for bounded per-client slope-limited rates; +- `RatioBudget` for framed uplink/downlink accounting; +- `PayloadPadder` for explicit observable volume addition; +- experimental handcrafted profile event generators. + +`traffic_masking_server.py` provides: + +- authenticated client enrollment; +- independent generator, RNG, limiter, and counters per client; +- fixed/floating rate mode and native profile mode; +- round-robin scheduling under a server-wide egress limiter; +- explicitly labelled total and per-client metrics. + +`traffic_masking_client.py` provides: + +- authenticated enrollment and server-source validation; +- configurable response-ratio accounting; +- keepalive, health timeout, and exponential reconnect handling; +- monotonic receive-rate windows and client-total metrics. + +## Shaping Modes + +In `rate` mode, `--mbps` selects a fixed per-client target. A +`--min-mbps/--max-mbps` pair selects a bounded floating target. The server keeps +enough logical demand available and limiters pace final framed bytes. + +In `profile` mode, `--profile` selects native event sizes and gaps. +`--max-mbps` is optional and only caps the offered load. Profiles are +handcrafted experimental inputs, not measured baselines. + +## Data Units + +1. A `ShapeEvent` declares logical byte volume and the gap after the event. +2. Optional padding adds byte volume with an explicit strategy. +3. `Packetizer` splits the result so authenticated UDP datagrams fit `--mtu`. +4. Per-client and aggregate limiters account the complete framed datagram. + +Mbps values are decimal application rates. IP, UDP, and enclosing encrypted +transport overhead require a separate observer measurement. + +## Security Boundary + +The control protocol prevents arbitrary unauthenticated destinations from being +enrolled for cover volume. It does not encrypt the UDP payload. Deployment +confidentiality and flow aggregation depend on the external encrypted transport. + +Cover traffic can add bytes but cannot cancel a real traffic spike or repair an +already excessive direction ratio. Statistical effectiveness must be evaluated +at the aggregate observer boundary with declared captures and metrics. diff --git a/traffic-masking/enhanced/__init__.py b/traffic-masking/enhanced/__init__.py deleted file mode 100644 index 62a159b..0000000 --- a/traffic-masking/enhanced/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Enhanced traffic masking components with advanced obfuscation techniques -""" - -from .timing import AdaptiveTimingModel -from .correlation import CorrelationBreaker -from .ml_resistance import MLResistantGenerator -from .entropy import EntropyEnhancer -from .state_machine import ProtocolStateMachine - -__all__ = [ - 'AdaptiveTimingModel', - 'CorrelationBreaker', - 'MLResistantGenerator', - 'EntropyEnhancer', - 'ProtocolStateMachine' -] - -__version__ = '2.0.0' diff --git a/traffic-masking/enhanced/correlation.py b/traffic-masking/enhanced/correlation.py deleted file mode 100644 index 77c452c..0000000 --- a/traffic-masking/enhanced/correlation.py +++ /dev/null @@ -1,419 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Correlation breaker for disrupting statistical patterns in traffic -""" - -import random -import math -from collections import deque -from typing import Dict, List, Optional, Tuple, Any -from enum import Enum - - -class TrafficProfile(Enum): - """Traffic profile enumeration""" - WEB_BROWSING = "web" - VIDEO_STREAMING = "video" - VOIP_CALL = "voip" - FILE_TRANSFER = "file" - GAMING = "gaming" - MIXED = "mixed" - - -class CorrelationBreaker: - """ - Breaks statistical correlations in traffic using Markov chains and - autocorrelation techniques to generate realistic packet size patterns. - """ - - def __init__(self): - """Initialize correlation breaker with Markov models.""" - self.markov_chains = self._build_all_markov_models() - self.current_state = 'medium' - self.last_size = 512 - self.size_history = deque(maxlen=20) - self.interval_history = deque(maxlen=20) - - # Autocorrelation parameters - self.autocorr_coefficient = 0.3 # 30% correlation with history - self.burst_mode = False - self.burst_remaining = 0 - - # Profile-specific state - self.current_profile = TrafficProfile.MIXED - self.profile_switch_counter = 0 - - # Statistical tracking - self.size_distribution = {'small': 0, 'medium': 0, 'large': 0} - self.total_packets = 0 - - def _build_all_markov_models(self) -> Dict[str, Dict[str, Dict[str, float]]]: - """Build Markov models for different traffic profiles.""" - return { - 'default': { - 'small': {'small': 0.35, 'medium': 0.45, 'large': 0.20}, - 'medium': {'small': 0.25, 'medium': 0.50, 'large': 0.25}, - 'large': {'small': 0.20, 'medium': 0.45, 'large': 0.35} - }, - 'web': { - 'small': {'small': 0.40, 'medium': 0.40, 'large': 0.20}, - 'medium': {'small': 0.30, 'medium': 0.40, 'large': 0.30}, - 'large': {'small': 0.35, 'medium': 0.35, 'large': 0.30} - }, - 'video': { - 'small': {'small': 0.10, 'medium': 0.20, 'large': 0.70}, - 'medium': {'small': 0.10, 'medium': 0.30, 'large': 0.60}, - 'large': {'small': 0.05, 'medium': 0.15, 'large': 0.80} - }, - 'voip': { - 'small': {'small': 0.80, 'medium': 0.15, 'large': 0.05}, - 'medium': {'small': 0.70, 'medium': 0.25, 'large': 0.05}, - 'large': {'small': 0.60, 'medium': 0.30, 'large': 0.10} - }, - 'file': { - 'small': {'small': 0.10, 'medium': 0.10, 'large': 0.80}, - 'medium': {'small': 0.10, 'medium': 0.20, 'large': 0.70}, - 'large': {'small': 0.05, 'medium': 0.10, 'large': 0.85} - }, - 'gaming': { - 'small': {'small': 0.60, 'medium': 0.30, 'large': 0.10}, - 'medium': {'small': 0.45, 'medium': 0.40, 'large': 0.15}, - 'large': {'small': 0.40, 'medium': 0.40, 'large': 0.20} - } - } - - def _get_markov_chain(self, profile: Optional[TrafficProfile] = None) -> Dict[str, Dict[str, float]]: - """Get appropriate Markov chain for the profile.""" - if profile is None: - profile = self.current_profile - - profile_map = { - TrafficProfile.WEB_BROWSING: 'web', - TrafficProfile.VIDEO_STREAMING: 'video', - TrafficProfile.VOIP_CALL: 'voip', - TrafficProfile.FILE_TRANSFER: 'file', - TrafficProfile.GAMING: 'gaming', - TrafficProfile.MIXED: 'default' - } - - chain_name = profile_map.get(profile, 'default') - return self.markov_chains.get(chain_name, self.markov_chains['default']) - - def get_correlated_size(self, base_size: int, profile: Optional[TrafficProfile] = None) -> int: - """ - Generate correlated packet size using Markov chains and autocorrelation. - - Args: - base_size: Base packet size suggestion - profile: Traffic profile to use - - Returns: - Correlated packet size - """ - self.total_packets += 1 - - if profile: - self.current_profile = profile - - # Determine current state based on base size - state = self._classify_size_state(base_size, profile) - - # Get Markov chain for current profile - markov_chain = self._get_markov_chain(profile) - - # Markov chain transition - transitions = markov_chain.get(state, markov_chain['medium']) - next_state = random.choices( - list(transitions.keys()), - weights=list(transitions.values()) - )[0] - - # Get size range for the state - size_range = self._get_size_range(next_state, profile) - - # Generate new size with autocorrelation - new_size = self._apply_autocorrelation(size_range, base_size) - - # Apply burst mode if active - if self.burst_mode: - new_size = self._apply_burst_mode(new_size) - - # Check for burst mode activation - if not self.burst_mode and random.random() < self._get_burst_probability(profile): - self._activate_burst_mode(profile) - - # Update history - self.size_history.append(new_size) - self.last_size = new_size - self.current_state = next_state - - # Update statistics - self.size_distribution[next_state] = self.size_distribution.get(next_state, 0) + 1 - - return new_size - - def _classify_size_state(self, size: int, profile: Optional[TrafficProfile]) -> str: - """Classify size into state category.""" - if profile == TrafficProfile.VOIP_CALL: - if size < 80: - return 'small' - elif size < 160: - return 'medium' - else: - return 'large' - elif profile == TrafficProfile.VIDEO_STREAMING: - if size < 1000: - return 'small' - elif size < 1300: - return 'medium' - else: - return 'large' - elif profile == TrafficProfile.GAMING: - if size < 100: - return 'small' - elif size < 300: - return 'medium' - else: - return 'large' - else: # Default classification - if size < 400: - return 'small' - elif size < 1000: - return 'medium' - else: - return 'large' - - def _get_size_range(self, state: str, profile: Optional[TrafficProfile]) -> Tuple[int, int]: - """Get size range for a given state and profile.""" - ranges = { - TrafficProfile.VOIP_CALL: { - 'small': (20, 80), - 'medium': (80, 160), - 'large': (160, 320) - }, - TrafficProfile.VIDEO_STREAMING: { - 'small': (800, 1000), - 'medium': (1000, 1300), - 'large': (1300, 1400) - }, - TrafficProfile.GAMING: { - 'small': (40, 100), - 'medium': (100, 300), - 'large': (300, 600) - }, - TrafficProfile.WEB_BROWSING: { - 'small': (64, 400), - 'medium': (400, 1000), - 'large': (1000, 1400) - }, - TrafficProfile.FILE_TRANSFER: { - 'small': (500, 800), - 'medium': (800, 1200), - 'large': (1200, 1400) - }, - TrafficProfile.MIXED: { - 'small': (64, 400), - 'medium': (400, 1000), - 'large': (1000, 1400) - } - } - - profile_ranges = ranges.get(profile if profile else TrafficProfile.MIXED, ranges[TrafficProfile.MIXED]) - return profile_ranges.get(state, (64, 1400)) - - def _apply_autocorrelation(self, size_range: Tuple[int, int], base_size: int) -> int: - """Apply autocorrelation with historical data.""" - min_size, max_size = size_range - - # Generate uncorrelated size - new_size = random.randint(min_size, max_size) - - # Apply autocorrelation if we have history - if self.size_history: - # Calculate weighted average of history - history_weights = [0.5 ** i for i in range(len(self.size_history))] - history_weights.reverse() - - weighted_sum = sum(w * s for w, s in zip(history_weights, self.size_history)) - weight_total = sum(history_weights) - - if weight_total > 0: - avg_history = weighted_sum / weight_total - else: - avg_history = new_size - - # Combine with autocorrelation coefficient - correlated = int(avg_history * self.autocorr_coefficient + new_size * (1 - self.autocorr_coefficient)) - - # Add small random perturbation - perturbation = random.randint(-20, 20) - correlated += perturbation - - # Ensure within bounds - new_size = max(min_size, min(max_size, correlated)) - - return new_size - - def _get_burst_probability(self, profile: Optional[TrafficProfile]) -> float: - """Get burst probability for a given profile.""" - burst_probs = { - TrafficProfile.WEB_BROWSING: 0.15, - TrafficProfile.VIDEO_STREAMING: 0.05, - TrafficProfile.VOIP_CALL: 0.02, - TrafficProfile.FILE_TRANSFER: 0.20, - TrafficProfile.GAMING: 0.10, - TrafficProfile.MIXED: 0.08 - } - return burst_probs.get(profile if profile else TrafficProfile.MIXED, 0.08) - - def _activate_burst_mode(self, profile: Optional[TrafficProfile]): - """Activate burst mode.""" - self.burst_mode = True - - # Determine burst length based on profile - burst_lengths = { - TrafficProfile.WEB_BROWSING: (5, 20), - TrafficProfile.VIDEO_STREAMING: (10, 30), - TrafficProfile.VOIP_CALL: (2, 5), - TrafficProfile.FILE_TRANSFER: (20, 100), - TrafficProfile.GAMING: (3, 10), - TrafficProfile.MIXED: (5, 25) - } - - min_burst, max_burst = burst_lengths.get(profile if profile else TrafficProfile.MIXED, (5, 20)) - self.burst_remaining = random.randint(min_burst, max_burst) - - def _apply_burst_mode(self, size: int) -> int: - """Apply burst mode modifications.""" - if self.burst_remaining > 0: - # Increase size during burst - burst_factor = random.uniform(1.2, 1.8) - size = int(size * burst_factor) - size = min(1400, size) # Cap at MTU - - self.burst_remaining -= 1 - - if self.burst_remaining <= 0: - self.burst_mode = False - - return size - - def get_correlated_interval(self, base_interval: float, profile: Optional[TrafficProfile] = None) -> float: - """ - Generate correlated inter-packet interval. - - Args: - base_interval: Base interval suggestion in seconds - profile: Traffic profile to use - - Returns: - Correlated interval in seconds - """ - # Profile-specific interval patterns - if profile == TrafficProfile.VOIP_CALL: - # VoIP has very regular intervals - interval = base_interval * random.uniform(0.98, 1.02) - elif profile == TrafficProfile.VIDEO_STREAMING: - # Video has frame-based intervals - frame_intervals = [0.008, 0.016, 0.033, 0.040] # Common frame rates - closest = min(frame_intervals, key=lambda x: abs(x - base_interval)) - interval = closest * random.uniform(0.95, 1.05) - elif profile == TrafficProfile.GAMING: - # Gaming has tick-based intervals - tick_rates = [0.016, 0.033, 0.050] # 60Hz, 30Hz, 20Hz - closest = min(tick_rates, key=lambda x: abs(x - base_interval)) - interval = closest * random.uniform(0.90, 1.10) - else: - # General correlation with history - if self.interval_history: - avg_history = sum(self.interval_history) / len(self.interval_history) - interval = avg_history * 0.4 + base_interval * 0.6 - interval *= random.uniform(0.85, 1.15) - else: - interval = base_interval * random.uniform(0.8, 1.2) - - # Add occasional outliers (realistic network behavior) - if random.random() < 0.02: # 2% outliers - if random.random() < 0.5: - interval *= random.uniform(0.1, 0.5) # Very short - else: - interval *= random.uniform(2.0, 5.0) # Very long - - self.interval_history.append(interval) - return max(0.0001, interval) - - def add_cross_correlation(self, sizes: List[int], intervals: List[float]) -> Tuple[List[int], List[float]]: - """ - Add cross-correlation between packet sizes and intervals. - - Args: - sizes: List of packet sizes - intervals: List of inter-packet intervals - - Returns: - Tuple of correlated sizes and intervals - """ - if len(sizes) != len(intervals): - return sizes, intervals - - correlated_sizes = [] - correlated_intervals = [] - - for i, (size, interval) in enumerate(zip(sizes, intervals)): - # Large packets often have longer intervals (processing time) - if size > 1200: - interval *= random.uniform(1.1, 1.3) - elif size < 100: - interval *= random.uniform(0.8, 0.95) - - # Consecutive small packets might be fragments - if i > 0 and sizes[i-1] < 100 and size < 100: - interval *= 0.5 # Fragments arrive quickly - - correlated_sizes.append(size) - correlated_intervals.append(interval) - - return correlated_sizes, correlated_intervals - - def get_statistics(self) -> Dict[str, Any]: - """Get correlation statistics.""" - stats = { - 'current_state': self.current_state, - 'burst_mode': self.burst_mode, - 'total_packets': self.total_packets, - 'autocorr_coefficient': self.autocorr_coefficient - } - - # Add size distribution percentages - if self.total_packets > 0: - for state in ['small', 'medium', 'large']: - count = self.size_distribution.get(state, 0) - stats[f'{state}_percentage'] = (count / self.total_packets) * 100 - - # Add history statistics - if self.size_history: - stats['avg_recent_size'] = sum(self.size_history) / len(self.size_history) - stats['stddev_recent_size'] = math.sqrt( - sum((x - stats['avg_recent_size'])**2 for x in self.size_history) / len(self.size_history) - ) - - if self.interval_history: - stats['avg_recent_interval'] = sum(self.interval_history) / len(self.interval_history) - - return stats - - def reset(self): - """Reset correlation breaker state.""" - self.current_state = 'medium' - self.last_size = 512 - self.size_history.clear() - self.interval_history.clear() - self.burst_mode = False - self.burst_remaining = 0 - self.size_distribution = {'small': 0, 'medium': 0, 'large': 0} - self.total_packets = 0 diff --git a/traffic-masking/enhanced/entropy.py b/traffic-masking/enhanced/entropy.py deleted file mode 100644 index 108de5f..0000000 --- a/traffic-masking/enhanced/entropy.py +++ /dev/null @@ -1,617 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Enhanced entropy module for generating realistic encrypted payloads -""" - -import os -import struct -import random -import hashlib -import math -from typing import Dict, Any, List, Optional -from collections import Counter -from enum import Enum - - -class ContentType(Enum): - """Content type enumeration for payload generation""" - COMPRESSED_VIDEO = "compressed_video" - COMPRESSED_AUDIO = "compressed_audio" - TLS_RECORD = "tls_record" - WEBRTC_SRTP = "webrtc_srtp" - QUIC_PACKET = "quic_packet" - SSH_PACKET = "ssh_packet" - MIXED = "mixed" - - -class CipherType(Enum): - """Cipher type for encryption simulation""" - STREAM = "stream" - BLOCK = "block" - AEAD = "aead" - - -class EntropyEnhancer: - """ - Enhances entropy characteristics of generated data to match real encrypted traffic. - Simulates various encryption methods and content types. - """ - - def __init__(self): - """Initialize entropy enhancer with cipher simulators.""" - self.cipher_blocks = {} - self.stream_state = os.urandom(32) # Stream cipher state - self.block_counter = 0 - - # Cache for performance - self.entropy_cache = {} - self.payload_cache = {} - - # Statistics - self.generated_bytes = 0 - self.entropy_measurements = [] - - def generate_realistic_encrypted_payload(self, size: int, - content_type: str = 'mixed', - cipher_type: Optional[CipherType] = None) -> bytes: - """ - Generate payload resembling real encrypted data. - - Args: - size: Payload size in bytes - content_type: Type of content being "encrypted" - cipher_type: Optional cipher type to simulate - - Returns: - Realistic encrypted-looking payload - """ - if size <= 0: - return b"" - - # Choose cipher type if not specified - if cipher_type is None: - cipher_type = self._select_cipher_type(content_type) - - # Generate base payload based on content type - if content_type == ContentType.COMPRESSED_VIDEO.value: - payload = self._generate_compressed_video_payload(size) - elif content_type == ContentType.COMPRESSED_AUDIO.value: - payload = self._generate_compressed_audio_payload(size) - elif content_type == ContentType.TLS_RECORD.value: - payload = self._generate_tls_record_payload(size) - elif content_type == ContentType.WEBRTC_SRTP.value: - payload = self._generate_webrtc_srtp_payload(size) - elif content_type == ContentType.QUIC_PACKET.value: - payload = self._generate_quic_packet_payload(size) - elif content_type == ContentType.SSH_PACKET.value: - payload = self._generate_ssh_packet_payload(size) - else: # mixed or unknown - payload = self._generate_mixed_payload(size) - - # Apply cipher simulation - payload = self._apply_cipher_simulation(payload, cipher_type) - - # Update statistics - self.generated_bytes += len(payload) - - return payload - - def _select_cipher_type(self, content_type: str) -> CipherType: - """Select appropriate cipher type based on content.""" - cipher_map = { - ContentType.TLS_RECORD.value: CipherType.AEAD, - ContentType.QUIC_PACKET.value: CipherType.AEAD, - ContentType.WEBRTC_SRTP.value: CipherType.STREAM, - ContentType.SSH_PACKET.value: CipherType.BLOCK, - ContentType.COMPRESSED_VIDEO.value: CipherType.STREAM, - ContentType.COMPRESSED_AUDIO.value: CipherType.STREAM, - } - return cipher_map.get(content_type, random.choice(list(CipherType))) - - def _generate_compressed_video_payload(self, size: int) -> bytes: - """Generate payload simulating encrypted compressed video.""" - # Video has structure even after encryption due to frame boundaries - header_size = min(32, size // 10) - - # NAL unit headers (lower entropy due to patterns) - header = bytearray() - for _ in range(header_size): - # Simulate H.264/H.265 NAL headers - if random.random() < 0.3: - # Start code patterns - header.append(random.choice([0x00, 0x01, 0x41, 0x61])) - else: - header.append(random.randint(0x20, 0x7F)) - - # Body with variable entropy based on frame type - body_parts = [] - remaining = size - header_size - - while remaining > 0: - # Simulate different frame types - frame_type = random.choices( - ['i_frame', 'p_frame', 'b_frame'], - weights=[0.1, 0.6, 0.3] - )[0] - - nal_size = min(remaining, random.choice([188, 1316, 1400])) - - if frame_type == 'i_frame': - # I-frames have more structure (lower entropy) - if random.random() < 0.3: - # Repeated macroblocks - pattern = os.urandom(16) - nal_data = bytearray() - for _ in range(nal_size // 16): - if random.random() < 0.7: - nal_data.extend(pattern) - else: - nal_data.extend(os.urandom(16)) - nal_data.extend(os.urandom(nal_size % 16)) - else: - nal_data = os.urandom(nal_size) - elif frame_type == 'p_frame': - # P-frames have medium entropy - nal_data = os.urandom(nal_size) - # Add some structure - for i in range(0, min(nal_size - 4, 100), 20): - nal_data = nal_data[:i] + bytes([0x00, 0x00, 0x01]) + nal_data[i+3:] - else: # b_frame - # B-frames have high entropy (most compressed) - nal_data = os.urandom(nal_size) - - body_parts.append(bytes(nal_data)) - remaining -= nal_size - - return bytes(header) + b''.join(body_parts) - - def _generate_compressed_audio_payload(self, size: int) -> bytes: - """Generate payload simulating encrypted compressed audio.""" - # Audio codecs have frame structure - frame_sizes = { - 'opus': [20, 40, 60], - 'aac': [128, 256, 512], - 'mp3': [144, 288, 576] - } - - codec = random.choice(list(frame_sizes.keys())) - frame_size = random.choice(frame_sizes[codec]) - - payload = bytearray() - - # Generate pattern for voice (has repetition) - voice_pattern = os.urandom(frame_size) - - while len(payload) < size: - if random.random() < 0.7: - # Voice activity - similar frames - frame = bytearray(voice_pattern) - # Add small variations - for _ in range(min(10, len(frame))): - idx = random.randint(0, len(frame) - 1) - frame[idx] ^= random.randint(1, 255) - payload.extend(frame) - else: - # Silence or noise - different pattern - if random.random() < 0.5: - # Silence (low entropy) - silence_frame = bytes([random.randint(0, 15)]) * frame_size - payload.extend(silence_frame) - else: - # Noise (high entropy) - payload.extend(os.urandom(frame_size)) - - return bytes(payload[:size]) - - def _generate_tls_record_payload(self, size: int) -> bytes: - """Generate payload simulating TLS 1.3 encrypted records.""" - records = [] - remaining = size - - while remaining > 0: - # TLS record sizes (max 2^14 + 256 for TLS 1.3) - max_record = 16384 + 256 - record_size = min(remaining, random.choice([ - 16384, # Full record - 8192, # Half record - 4096, # Quarter record - 1024, # Small record - random.randint(100, 1400) # Variable - ])) - - # TLS 1.3 encrypted record structure - # 5-byte header + encrypted content + 16-byte AEAD tag - - # Record header - record_type = 0x17 # Application data (everything is 0x17 in TLS 1.3) - tls_version = 0x0303 # TLS 1.2 for compatibility - content_length = min(record_size, max_record) - - header = struct.pack('!BHH', record_type, tls_version, content_length) - - # Encrypted content - if content_length > 16: - # Content + 1-byte content type + padding - actual_content = content_length - 16 # Reserve for AEAD tag - - # High entropy encrypted data - content = os.urandom(actual_content) - - # AEAD tag (appears random but is deterministic in real TLS) - tag = os.urandom(16) - - record = header + content + tag - else: - # Small record, all random - record = header + os.urandom(content_length) - - records.append(record) - remaining -= len(record) - - result = b''.join(records) - return result[:size] - - def _generate_webrtc_srtp_payload(self, size: int) -> bytes: - """Generate payload simulating WebRTC SRTP packets.""" - # SRTP header (12 bytes) + encrypted payload + auth tag (10 bytes typically) - if size < 22: - return os.urandom(size) - - # RTP header - version = 2 - padding = 0 - extension = random.randint(0, 1) - cc = 0 # CSRC count - marker = random.randint(0, 1) - payload_type = random.choice([111, 96, 97, 98]) # Common WebRTC PTs - - sequence = random.randint(0, 65535) - timestamp = random.randint(0, 2**32 - 1) - ssrc = random.randint(0, 2**32 - 1) - - header = struct.pack('!BBHII', - (version << 6) | (padding << 5) | (extension << 4) | cc, - (marker << 7) | payload_type, - sequence, timestamp, ssrc) - - # Extension header if present - if extension: - ext_profile = random.randint(0, 65535) - ext_length = random.randint(1, 10) - ext_header = struct.pack('!HH', ext_profile, ext_length) - ext_data = os.urandom(ext_length * 4) - header = header + ext_header + ext_data - - # Encrypted payload - payload_size = max(0, size - len(header) - 10) - encrypted_payload = os.urandom(payload_size) - - # SRTP auth tag - auth_tag = os.urandom(10) - - return header + encrypted_payload + auth_tag - - def _generate_quic_packet_payload(self, size: int) -> bytes: - """Generate payload simulating QUIC encrypted packets.""" - # QUIC has complex header structure but payload is AEAD encrypted - - # Short header for 1-RTT packets (most common) - flags = 0x40 # Short header, key phase 0 - - # Destination connection ID (variable length) - dcid_len = random.choice([0, 8, 16]) - dcid = os.urandom(dcid_len) if dcid_len > 0 else b'' - - # Packet number (encrypted) - pn_length = random.choice([1, 2, 3, 4]) - pn_encrypted = os.urandom(pn_length) - - header = bytes([flags]) + dcid + pn_encrypted - - # AEAD encrypted payload + tag - remaining = max(0, size - len(header)) - if remaining > 16: - payload = os.urandom(remaining - 16) - auth_tag = os.urandom(16) - return header + payload + auth_tag - else: - return header + os.urandom(remaining) - - def _generate_ssh_packet_payload(self, size: int) -> bytes: - """Generate payload simulating SSH encrypted packets.""" - # SSH uses block ciphers with MAC or AEAD - - # Decide if using MAC - use_mac = random.random() < 0.5 - mac_size = 32 if use_mac else 0 # HMAC-SHA256 - - # Calculate content size (excluding MAC if present) - content_size = max(16, size - mac_size) # At least one block - - # Ensure block alignment (common block size is 16) - block_size = 16 - aligned_size = ((content_size + block_size - 1) // block_size) * block_size - - # Generate blocks with subtle patterns (CBC mode characteristics) - blocks = [] - for i in range(aligned_size // block_size): - if i == 0: - # First block is IV (random) - block = os.urandom(block_size) - else: - # Subsequent blocks have CBC chaining effect - if random.random() < 0.1: - # Occasionally similar blocks (repeated commands) - block = blocks[-1] - # XOR with something to simulate CBC - block = bytes(a ^ b for a, b in zip(block, os.urandom(block_size))) - else: - block = os.urandom(block_size) - blocks.append(block) - - payload = b''.join(blocks) - - # Add MAC if using it - if use_mac: - mac = os.urandom(mac_size) - payload = payload[:content_size] + mac - - # Ensure exactly size bytes - if len(payload) < size: - payload = payload + os.urandom(size - len(payload)) - elif len(payload) > size: - payload = payload[:size] - - return payload - - def _generate_mixed_payload(self, size: int) -> bytes: - """Generate mixed encrypted payload.""" - # Mix different encryption patterns - chunks = [] - remaining = size - - while remaining > 0: - chunk_type = random.choices( - ['stream', 'block', 'aead', 'structured'], - weights=[0.3, 0.3, 0.3, 0.1] - )[0] - - chunk_size = min(remaining, random.randint(64, 1400)) - - if chunk_type == 'stream': - # Pure random (stream cipher) - chunk = os.urandom(chunk_size) - elif chunk_type == 'block': - # Block-aligned with padding - blocks = chunk_size // 16 - chunk = os.urandom(blocks * 16) - if len(chunk) < chunk_size: - # PKCS#7 padding - pad_len = chunk_size - len(chunk) - chunk += bytes([pad_len]) * pad_len - elif chunk_type == 'aead': - # AEAD with tag - if chunk_size > 16: - chunk = os.urandom(chunk_size - 16) + os.urandom(16) - else: - chunk = os.urandom(chunk_size) - else: # structured - # Some structure (like TLS records) - if chunk_size > 5: - header = struct.pack('!BHH', 0x17, 0x0303, chunk_size - 5) - chunk = header + os.urandom(chunk_size - 5) - else: - chunk = os.urandom(chunk_size) - - chunks.append(chunk[:chunk_size]) - remaining -= chunk_size - - return b''.join(chunks) - - def _apply_cipher_simulation(self, payload: bytes, cipher_type: CipherType) -> bytes: - """Apply cipher-specific characteristics to payload.""" - if cipher_type == CipherType.STREAM: - # Stream cipher - XOR with keystream - keystream = self._generate_keystream(len(payload)) - return bytes(a ^ b for a, b in zip(payload, keystream)) - - elif cipher_type == CipherType.BLOCK: - # Block cipher - ensure alignment and add patterns - block_size = 16 - aligned_len = ((len(payload) + block_size - 1) // block_size) * block_size - - if len(payload) < aligned_len: - # Add PKCS#7 padding - pad_len = aligned_len - len(payload) - payload = payload + bytes([pad_len]) * pad_len - - # Simulate ECB/CBC patterns - result = bytearray() - for i in range(0, len(payload), block_size): - block = payload[i:i+block_size] - if random.random() < 0.05: # 5% repeated blocks (ECB weakness) - encrypted = hashlib.md5(b'ecb' + bytes([self.block_counter % 256])).digest() - else: - encrypted = hashlib.md5(block + bytes([self.block_counter])).digest() - result.extend(encrypted[:block_size]) - self.block_counter += 1 - - return bytes(result[:len(payload)]) - - else: # AEAD - # AEAD - add authentication tag - if len(payload) > 16: - return payload - else: - # Too small, just return high entropy - return os.urandom(len(payload)) - - def _generate_keystream(self, length: int) -> bytes: - """Generate keystream for stream cipher simulation.""" - keystream = bytearray() - state = self.stream_state - - while len(keystream) < length: - # Simple PRNG-based keystream - state = hashlib.sha256(state).digest() - keystream.extend(state) - - self.stream_state = state # Update state - return bytes(keystream[:length]) - - def calculate_entropy(self, data: bytes) -> float: - """ - Calculate Shannon entropy of data. - - Args: - data: Input data - - Returns: - Normalized entropy (0.0 to 1.0) - """ - if not data: - return 0.0 - - # Use cache for performance - data_hash = hashlib.md5(data).hexdigest() - if data_hash in self.entropy_cache: - return self.entropy_cache[data_hash] - - # Count byte frequencies - byte_counts = Counter(data) - data_len = len(data) - - # Calculate Shannon entropy - entropy = 0.0 - for count in byte_counts.values(): - if count > 0: - p = count / data_len - entropy -= p * math.log2(p) - - # Normalize to 0-1 range (max entropy is 8 bits) - normalized = entropy / 8.0 - - # Cache result - self.entropy_cache[data_hash] = normalized - - # Record measurement - self.entropy_measurements.append(normalized) - if len(self.entropy_measurements) > 1000: - self.entropy_measurements.pop(0) - - return normalized - - def analyze_payload_characteristics(self, payload: bytes) -> Dict[str, Any]: - """ - Analyze characteristics of a payload. - - Args: - payload: Payload to analyze - - Returns: - Dictionary of characteristics - """ - if not payload: - return {'error': 'Empty payload'} - - # Basic metrics - entropy = self.calculate_entropy(payload) - - # Byte distribution analysis - byte_counts = Counter(payload) - most_common = byte_counts.most_common(10) - - # Chi-square test for randomness - expected = len(payload) / 256 - chi_square = sum((count - expected) ** 2 / expected - for count in byte_counts.values()) - - # Block cipher detection (look for repeated 16-byte blocks) - block_size = 16 - blocks = [payload[i:i+block_size] - for i in range(0, len(payload) - block_size + 1, block_size)] - unique_blocks = len(set(blocks)) - repeated_blocks = len(blocks) - unique_blocks - - # Pattern detection - patterns = self._detect_patterns(payload) - - return { - 'size': len(payload), - 'entropy': entropy, - 'entropy_quality': self._classify_entropy(entropy), - 'most_common_bytes': most_common[:5], - 'chi_square': chi_square, - 'randomness_quality': 'good' if chi_square < 300 else 'poor', - 'repeated_blocks': repeated_blocks, - 'block_cipher_likely': repeated_blocks > 2, - 'patterns_detected': patterns - } - - def _classify_entropy(self, entropy: float) -> str: - """Classify entropy level.""" - if entropy < 0.5: - return 'very_low' - elif entropy < 0.7: - return 'low' - elif entropy < 0.9: - return 'medium' - elif entropy < 0.95: - return 'high' - else: - return 'very_high' - - def _detect_patterns(self, data: bytes) -> List[str]: - """Detect common patterns in data.""" - patterns = [] - - # Check for TLS-like headers - if len(data) >= 5: - if data[0] in [0x14, 0x15, 0x16, 0x17] and data[1:3] == b'\x03\x03': - patterns.append('tls_like') - - # Check for null bytes - null_ratio = data.count(0) / len(data) - if null_ratio > 0.1: - patterns.append('high_null_bytes') - - # Check for repeating sequences - for pattern_len in [2, 4, 8, 16]: - if len(data) >= pattern_len * 2: - for i in range(len(data) - pattern_len * 2): - if data[i:i+pattern_len] == data[i+pattern_len:i+pattern_len*2]: - patterns.append(f'repeat_{pattern_len}') - break - - return list(set(patterns)) - - def get_statistics(self) -> Dict[str, Any]: - """Get entropy enhancer statistics.""" - stats = { - 'generated_bytes': self.generated_bytes, - 'generated_mb': self.generated_bytes / (1024 * 1024), - 'cache_size': len(self.entropy_cache), - 'measurements': len(self.entropy_measurements) - } - - if self.entropy_measurements: - stats['avg_entropy'] = sum(self.entropy_measurements) / len(self.entropy_measurements) - stats['min_entropy'] = min(self.entropy_measurements) - stats['max_entropy'] = max(self.entropy_measurements) - - return stats - - def reset(self): - """Reset enhancer state.""" - self.cipher_blocks.clear() - self.stream_state = os.urandom(32) - self.block_counter = 0 - self.entropy_cache.clear() - self.payload_cache.clear() - self.generated_bytes = 0 - self.entropy_measurements.clear() diff --git a/traffic-masking/enhanced/ml_resistance.py b/traffic-masking/enhanced/ml_resistance.py deleted file mode 100644 index cce1bc2..0000000 --- a/traffic-masking/enhanced/ml_resistance.py +++ /dev/null @@ -1,575 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Machine Learning resistant traffic generation module -""" - -import random -import time -import math -from typing import Dict, List, Tuple, Any -from collections import deque - - -class MLResistantGenerator: - """ - Generator that produces traffic patterns resistant to ML-based detection. - Uses adversarial techniques and real traffic mimicry to evade classification. - """ - - def __init__(self): - """Initialize ML-resistant generator with traffic patterns and models.""" - self.real_traffic_patterns = self._load_real_traffic_patterns() - self.pattern_cache = {} - self.anomaly_rate = 0.05 # 5% anomalous packets like real traffic - - # Adversarial parameters - self.noise_factor = 0.15 # Amount of noise to add - self.pattern_switching_rate = 0.1 # Rate of pattern switching - - # Feature obfuscation - self.feature_history = deque(maxlen=100) - self.current_pattern = 'mixed' - self.pattern_counter = 0 - - # Statistics for adaptive behavior - self.packet_count = 0 - self.pattern_usage = {} - - def _load_real_traffic_patterns(self) -> Dict[str, Dict[str, Any]]: - """ - Load patterns that mimic real traffic characteristics. - These patterns are based on empirical observations of real traffic. - """ - return { - 'web': { - 'sizes': [64, 128, 256, 512, 576, 1024, 1400], - 'size_weights': [0.1, 0.1, 0.15, 0.2, 0.15, 0.15, 0.15], - 'intervals': [0.001, 0.005, 0.01, 0.02, 0.05, 0.1], - 'interval_weights': [0.2, 0.25, 0.2, 0.15, 0.15, 0.05], - 'burst_probability': 0.3, - 'idle_probability': 0.2, - 'session_length': (50, 500), - 'features': { - 'avg_packet_size': 680, - 'size_variance': 400, - 'timing_regularity': 0.3 - } - }, - 'video': { - 'sizes': [1200, 1300, 1350, 1380, 1400], - 'size_weights': [0.15, 0.2, 0.3, 0.2, 0.15], - 'intervals': [0.008, 0.016, 0.033, 0.040], - 'interval_weights': [0.25, 0.35, 0.25, 0.15], - 'burst_probability': 0.1, - 'idle_probability': 0.05, - 'session_length': (200, 2000), - 'features': { - 'avg_packet_size': 1340, - 'size_variance': 80, - 'timing_regularity': 0.8 - } - }, - 'voip': { - 'sizes': [20, 40, 60, 80, 160], - 'size_weights': [0.1, 0.2, 0.3, 0.3, 0.1], - 'intervals': [0.020, 0.020, 0.020], - 'interval_weights': [0.95, 0.025, 0.025], - 'burst_probability': 0.02, - 'idle_probability': 0.01, - 'session_length': (500, 5000), - 'features': { - 'avg_packet_size': 80, - 'size_variance': 40, - 'timing_regularity': 0.95 - } - }, - 'gaming': { - 'sizes': [40, 80, 120, 200, 400, 800], - 'size_weights': [0.25, 0.3, 0.2, 0.15, 0.08, 0.02], - 'intervals': [0.016, 0.033, 0.050], - 'interval_weights': [0.5, 0.35, 0.15], - 'burst_probability': 0.4, - 'idle_probability': 0.1, - 'session_length': (100, 1000), - 'features': { - 'avg_packet_size': 150, - 'size_variance': 120, - 'timing_regularity': 0.6 - } - }, - 'file': { - 'sizes': [1400, 1400, 1400, 1400], - 'size_weights': [0.9, 0.05, 0.03, 0.02], - 'intervals': [0.001, 0.002, 0.003], - 'interval_weights': [0.6, 0.3, 0.1], - 'burst_probability': 0.8, - 'idle_probability': 0.3, - 'session_length': (50, 1000), - 'features': { - 'avg_packet_size': 1380, - 'size_variance': 50, - 'timing_regularity': 0.4 - } - }, - 'mixed': { - 'sizes': [64, 128, 256, 512, 1024, 1400], - 'size_weights': [0.15, 0.15, 0.2, 0.2, 0.15, 0.15], - 'intervals': [0.001, 0.01, 0.02, 0.05, 0.1], - 'interval_weights': [0.2, 0.25, 0.25, 0.2, 0.1], - 'burst_probability': 0.25, - 'idle_probability': 0.15, - 'session_length': (100, 1000), - 'features': { - 'avg_packet_size': 600, - 'size_variance': 500, - 'timing_regularity': 0.5 - } - } - } - - def generate_adversarial_packet(self, profile: str, timing_model: Any = None) -> Tuple[int, float]: - """ - Generate packet that evades ML detection using adversarial techniques. - - Args: - profile: Traffic profile name - timing_model: Optional timing model for realistic delays - - Returns: - Tuple of (packet_size, interval) - """ - self.packet_count += 1 - - # Get base pattern - pattern = self.real_traffic_patterns.get( - profile, - self.real_traffic_patterns['mixed'] - ) - - # Decide if this should be an anomaly - if random.random() < self.anomaly_rate: - size, interval = self._generate_anomaly(pattern) - else: - # Normal packet with adversarial perturbations - size, interval = self._generate_adversarial_normal(pattern) - # Ensure normal packets don't look like anomalies - size = max(65, min(1399, size)) # Avoid tiny/jumbo - interval = min(0.99, interval) # Avoid idle periods - - # Apply feature obfuscation - size, interval = self._obfuscate_features(size, interval, pattern) - - # Apply timing model if provided - if timing_model: - network_load = self._estimate_network_load() - interval = timing_model.get_delay(size, network_load) - - # Update statistics - self._update_feature_history(size, interval) - - return size, interval - - def _generate_anomaly(self, pattern: Dict[str, Any]) -> Tuple[int, float]: - """ - Generate anomalous packet that exists in real traffic. - These anomalies help confuse ML classifiers. - """ - # Weight anomaly types to reduce extreme cases - anomaly_types = ['tiny', 'jumbo', 'burst', 'idle', 'fragment', 'duplicate'] - anomaly_weights = [0.1, 0.1, 0.3, 0.1, 0.2, 0.2] # Reduce tiny/jumbo/idle - anomaly_type = random.choices(anomaly_types, weights=anomaly_weights)[0] - - if anomaly_type == 'tiny': - # Tiny control packets - size = random.randint(1, 64) - interval = random.choice([0.001, 0.01, 0.1]) - elif anomaly_type == 'jumbo': - # Maximum size packets - size = 1400 - interval = random.uniform(0.001, 0.005) - elif anomaly_type == 'burst': - # Burst traffic - size = random.choice(pattern['sizes']) - interval = random.uniform(0.0001, 0.001) - elif anomaly_type == 'idle': - # Long idle period - size = random.randint(64, 128) - interval = random.uniform(1.0, 5.0) - elif anomaly_type == 'fragment': - # Fragmented packet - size = random.randint(500, 700) - interval = 0.0001 - else: # duplicate - # Duplicate/retransmission - if self.feature_history: - last = self.feature_history[-1] - size = last.get('size', 512) - interval = 0.2 # Retransmission timeout - else: - size = random.choice(pattern['sizes']) - interval = 0.2 - - return size, interval - - def _generate_adversarial_normal(self, pattern: Dict[str, Any]) -> Tuple[int, float]: - """ - Generate normal packet with adversarial perturbations. - """ - # Select base values using weighted random - size = random.choices( - pattern['sizes'], - weights=pattern['size_weights'] - )[0] - - base_interval = random.choices( - pattern['intervals'], - weights=pattern['interval_weights'] - )[0] - - # Apply adversarial perturbations - size = self._apply_adversarial_noise(size, 'size') - interval = self._apply_adversarial_noise(base_interval, 'interval') - - # Apply burst or idle based on profile (but less extreme) - if random.random() < pattern['burst_probability'] * 0.5: # Reduce burst frequency - interval *= random.uniform(0.1, 0.3) # Less extreme burst - elif random.random() < pattern['idle_probability'] * 0.3: # Reduce idle frequency - interval *= random.uniform(2, 5) # Less extreme idle - else: - interval *= random.uniform(0.8, 1.2) # Normal variation - - return size, interval - - def _apply_adversarial_noise(self, value: float, value_type: str) -> float: - """ - Apply adversarial noise to confuse ML classifiers. - """ - if value_type == 'size': - # Add Gaussian noise - noise = random.gauss(0, value * self.noise_factor) - value = int(value + noise) - - # Occasionally shift to boundary values (adversarial examples) - if random.random() < 0.05: - boundaries = [64, 128, 256, 512, 1024, 1400] - closest = min(boundaries, key=lambda x: abs(x - value)) - # Shift slightly off boundary to confuse classifiers - value = closest + random.randint(-10, 10) - - value = max(1, min(1400, value)) - - elif value_type == 'interval': - # Log-normal noise for intervals - log_value = math.log(max(0.0001, value)) - noise = random.gauss(0, self.noise_factor) - value = math.exp(log_value + noise) - - # Occasionally use exact protocol timings (confusing) - if random.random() < 0.05: - protocol_timings = [0.001, 0.008, 0.016, 0.020, 0.033, 0.040] - value = random.choice(protocol_timings) * random.uniform(0.99, 1.01) - - return value - - def _obfuscate_features(self, size: int, interval: float, pattern: Dict[str, Any]) -> Tuple[int, float]: - """ - Obfuscate statistical features that ML models use. - """ - if not self.feature_history: - return size, interval - - # Calculate current features - recent_sizes = [f['size'] for f in list(self.feature_history)[-20:]] - if recent_sizes: - current_avg = sum(recent_sizes) / len(recent_sizes) - target_avg = pattern['features']['avg_packet_size'] - - # Adjust size to move average towards target - if current_avg > target_avg * 1.2: - # We're too high, bias towards smaller packets - size = int(size * random.uniform(0.6, 0.9)) - elif current_avg < target_avg * 0.8: - # We're too low, bias towards larger packets - size = int(size * random.uniform(1.1, 1.4)) - - # Add controlled variance - target_variance = pattern['features']['size_variance'] - current_variance = math.sqrt( - sum((s - current_avg)**2 for s in recent_sizes) / len(recent_sizes) - ) if len(recent_sizes) > 1 else target_variance - - if current_variance < target_variance * 0.8: - # Add more variance - size = int(size + random.gauss(0, target_variance * 0.5)) - - # Ensure bounds - size = max(1, min(1400, size)) - interval = max(0.0001, interval) - - return size, interval - - def add_protocol_artifacts(self, packets: List[Dict[str, Any]], protocol: str) -> List[Dict[str, Any]]: - """ - Add protocol-specific artifacts to traffic. - - Args: - packets: List of packet dictionaries - protocol: Protocol name - - Returns: - Enhanced packet list with artifacts - """ - enhanced = [] - - for i, pkt in enumerate(packets): - enhanced.append(pkt) - - # TCP-like artifacts - if protocol in ['web', 'file']: - # SYN/ACK patterns at session start - if i < 3: - enhanced.append({ - 'size': random.randint(40, 60), - 'time': pkt['time'] + 0.001, - 'type': 'handshake' - }) - - # Retransmissions - if random.random() < 0.001: - enhanced.append({ - 'size': pkt['size'], - 'time': pkt['time'] + random.uniform(0.2, 1.0), - 'type': 'retransmission' - }) - - # ACK packets - if random.random() < 0.1: - enhanced.append({ - 'size': random.randint(40, 60), - 'time': pkt['time'] + 0.001, - 'type': 'ack' - }) - - # QUIC-like artifacts - if protocol in ['web', 'video']: - # QUIC ACK frames - if random.random() < 0.15: - enhanced.append({ - 'size': random.randint(20, 80), - 'time': pkt['time'] + 0.001, - 'type': 'quic_ack' - }) - - # Connection migration - if random.random() < 0.001: - enhanced.append({ - 'size': random.randint(100, 200), - 'time': pkt['time'] + 0.01, - 'type': 'migration' - }) - - # RTP/RTCP for media - if protocol in ['video', 'voip']: - # RTCP reports - if i > 0 and i % 100 == 0: - enhanced.append({ - 'size': random.randint(70, 90), - 'time': pkt['time'] + random.uniform(0.01, 0.1), - 'type': 'rtcp' - }) - - # FEC packets - if random.random() < 0.05: - enhanced.append({ - 'size': pkt.get('size', 100) // 2, - 'time': pkt['time'] + 0.001, - 'type': 'fec' - }) - - # Gaming-specific - if protocol == 'gaming': - # State updates - if random.random() < 0.2: - enhanced.append({ - 'size': random.randint(100, 300), - 'time': pkt['time'] + 0.001, - 'type': 'state_update' - }) - - # Ping/keepalive - if i > 0 and i % 30 == 0: - enhanced.append({ - 'size': random.randint(20, 40), - 'time': pkt['time'] + 0.001, - 'type': 'ping' - }) - - return enhanced - - def generate_session(self, profile: str, duration: float) -> List[Dict[str, Any]]: - """ - Generate a complete session with realistic patterns. - - Args: - profile: Traffic profile - duration: Session duration in seconds - - Returns: - List of packets with timing - """ - pattern = self.real_traffic_patterns.get(profile, self.real_traffic_patterns['mixed']) - packets = [] - current_time = 0.0 - - # Session phases - phases = ['start', 'active', 'idle', 'active', 'end'] - phase_durations = self._calculate_phase_durations(duration, len(phases)) - - for phase, phase_duration in zip(phases, phase_durations): - phase_packets = self._generate_phase_traffic( - phase, profile, pattern, phase_duration, current_time - ) - packets.extend(phase_packets) - if phase_packets: - current_time = phase_packets[-1]['time'] - - # Add protocol artifacts - packets = self.add_protocol_artifacts(packets, profile) - - # Sort by time - packets.sort(key=lambda x: x['time']) - - return packets - - def _calculate_phase_durations(self, total_duration: float, num_phases: int) -> List[float]: - """Calculate durations for each phase of a session.""" - # Distribute duration with some randomness - base_duration = total_duration / num_phases - durations = [] - - remaining = total_duration - for i in range(num_phases - 1): - duration = base_duration * random.uniform(0.5, 1.5) - duration = min(duration, remaining * 0.8) - durations.append(duration) - remaining -= duration - - durations.append(remaining) - return durations - - def _generate_phase_traffic(self, phase: str, profile: str, pattern: Dict[str, Any], - duration: float, start_time: float) -> List[Dict[str, Any]]: - """Generate traffic for a specific phase.""" - packets = [] - current_time = start_time - end_time = start_time + duration - - while current_time < end_time: - size, interval = self.generate_adversarial_packet(profile) - - # Adjust for phase - if phase == 'start': - # Handshake and initial burst - interval *= 0.5 - if len(packets) < 10: - size = random.randint(40, 200) - elif phase == 'idle': - # Sparse keepalive traffic - interval *= random.uniform(10, 50) - size = random.randint(40, 100) - elif phase == 'end': - # Closing sequence - interval *= random.uniform(1, 3) - if len(packets) > 5: - break - - packets.append({ - 'size': size, - 'time': current_time, - 'phase': phase - }) - - current_time += interval - - return packets - - def _estimate_network_load(self) -> float: - """Estimate current network load from recent history.""" - if not self.feature_history: - return 0.5 - - recent = list(self.feature_history)[-50:] - if len(recent) < 2: - return 0.5 - - # Calculate packet rate - time_span = recent[-1].get('time', 1) - recent[0].get('time', 0) - if time_span <= 0: - return 0.5 - - packet_rate = len(recent) / time_span - - # Estimate load (normalize to 0-1) - # Assume 1000 pps = full load - load = min(1.0, packet_rate / 1000) - - # Add some randomness - load += random.gauss(0, 0.1) - - return max(0.0, min(1.0, load)) - - def _update_feature_history(self, size: int, interval: float): - """Update feature history for statistics.""" - self.feature_history.append({ - 'size': size, - 'interval': interval, - 'time': time.time() - }) - - # Update pattern usage - if self.current_pattern not in self.pattern_usage: - self.pattern_usage[self.current_pattern] = 0 - self.pattern_usage[self.current_pattern] += 1 - - def get_statistics(self) -> Dict[str, Any]: - """Get generator statistics.""" - stats = { - 'packet_count': self.packet_count, - 'current_pattern': self.current_pattern, - 'anomaly_rate': self.anomaly_rate, - 'noise_factor': self.noise_factor, - 'pattern_usage': dict(self.pattern_usage) - } - - if self.feature_history: - recent = list(self.feature_history)[-100:] - sizes = [f['size'] for f in recent] - intervals = [f['interval'] for f in recent if 'interval' in f] - - if sizes: - stats['avg_size'] = sum(sizes) / len(sizes) - stats['size_variance'] = math.sqrt( - sum((s - stats['avg_size'])**2 for s in sizes) / len(sizes) - ) if len(sizes) > 1 else 0 - - if intervals: - stats['avg_interval'] = sum(intervals) / len(intervals) - stats['interval_variance'] = math.sqrt( - sum((i - stats['avg_interval'])**2 for i in intervals) / len(intervals) - ) if len(intervals) > 1 else 0 - - return stats - - def reset(self): - """Reset generator state.""" - self.feature_history.clear() - self.pattern_cache.clear() - self.packet_count = 0 - self.pattern_usage.clear() - self.current_pattern = 'mixed' - self.pattern_counter = 0 diff --git a/traffic-masking/enhanced/state_machine.py b/traffic-masking/enhanced/state_machine.py deleted file mode 100644 index a020a2f..0000000 --- a/traffic-masking/enhanced/state_machine.py +++ /dev/null @@ -1,750 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Protocol state machine for realistic traffic pattern generation -""" - -import random -import time -from typing import Dict, Any, Tuple -from enum import Enum -from collections import deque, defaultdict - - -class ProtocolType(Enum): - """Supported protocol types""" - TLS = "tls" - QUIC = "quic" - WEBRTC = "webrtc" - SSH = "ssh" - HTTP2 = "http2" - HTTP3 = "http3" - GENERIC = "generic" - - -class ConnectionState(Enum): - """Common connection states""" - INIT = "init" - HANDSHAKE = "handshake" - ESTABLISHED = "established" - DATA = "data" - CLOSING = "closing" - CLOSED = "closed" - - -class ProtocolStateMachine: - """ - Protocol state machine for generating realistic traffic patterns. - Simulates protocol-specific state transitions and behaviors. - """ - - def __init__(self, protocol: str): - """ - Initialize state machine for a specific protocol. - - Args: - protocol: Protocol name (tls, quic, webrtc, ssh, http2, http3, generic) - """ - self.protocol = self._validate_protocol(protocol) - self.current_state = 'init' - self.state_transitions = self._build_transitions(protocol) - self.state_characteristics = self._build_characteristics(protocol) - - # State tracking - self.state_history = deque(maxlen=100) - self.state_timers = {} - self.state_counts = defaultdict(int) - - # Protocol-specific parameters - self.handshake_complete = False - self.data_transferred = 0 - self.connection_start = time.time() - self.last_state_change = time.time() - - # Session parameters - self.session_id = random.randint(0, 2**32 - 1) - self.rtt_estimate = 0.02 # 20ms default - self.congestion_window = 10 - - def _validate_protocol(self, protocol: str) -> str: - """Validate and normalize protocol name.""" - try: - return ProtocolType(protocol.lower()).value - except (ValueError, AttributeError): - return ProtocolType.GENERIC.value - - def _build_transitions(self, protocol: str) -> Dict[str, Dict[str, float]]: - """ - Build state transition probabilities for the protocol. - - Returns: - Dictionary mapping states to transition probabilities - """ - transitions = { - 'tls': { - 'init': {'handshake': 0.95, 'closed': 0.05}, - 'handshake': { - 'handshake': 0.2, # Multiple handshake messages - 'data': 0.75, - 'closing': 0.03, - 'closed': 0.02 - }, - 'data': { - 'data': 0.94, - 'closing': 0.05, - 'closed': 0.01 - }, - 'closing': {'closed': 0.9, 'data': 0.1}, - 'closed': {'init': 0.8, 'closed': 0.2} - }, - 'quic': { - 'init': {'initial': 0.95, 'closed': 0.05}, - 'initial': { - 'handshake': 0.9, - 'retry': 0.05, - 'closed': 0.05 - }, - 'retry': {'initial': 0.8, 'closed': 0.2}, - 'handshake': { - 'handshake': 0.3, - 'application': 0.65, - 'closing': 0.03, - 'closed': 0.02 - }, - 'application': { - 'application': 0.96, - 'closing': 0.03, - 'closed': 0.01 - }, - 'closing': {'closed': 0.95, 'application': 0.05}, - 'closed': {'init': 0.7, 'closed': 0.3} - }, - 'webrtc': { - 'init': {'stun': 0.95, 'closed': 0.05}, - 'stun': { - 'stun': 0.3, # Multiple STUN requests - 'turn': 0.2, - 'dtls': 0.45, - 'closed': 0.05 - }, - 'turn': { - 'turn': 0.2, - 'dtls': 0.75, - 'closed': 0.05 - }, - 'dtls': { - 'dtls': 0.2, - 'srtp': 0.75, - 'closed': 0.05 - }, - 'srtp': { - 'srtp': 0.90, - 'rtcp': 0.08, - 'closing': 0.01, - 'closed': 0.01 - }, - 'rtcp': { - 'srtp': 0.95, - 'rtcp': 0.03, - 'closing': 0.01, - 'closed': 0.01 - }, - 'closing': {'closed': 0.9, 'srtp': 0.1}, - 'closed': {'init': 0.6, 'closed': 0.4} - }, - 'ssh': { - 'init': {'handshake': 0.95, 'closed': 0.05}, - 'handshake': { - 'auth': 0.9, - 'closed': 0.1 - }, - 'auth': { - 'auth': 0.2, # Multiple auth attempts - 'session': 0.75, - 'closed': 0.05 - }, - 'session': { - 'session': 0.93, - 'channel': 0.05, - 'closing': 0.01, - 'closed': 0.01 - }, - 'channel': { - 'session': 0.8, - 'channel': 0.15, - 'closing': 0.04, - 'closed': 0.01 - }, - 'closing': {'closed': 0.95, 'session': 0.05}, - 'closed': {'init': 0.5, 'closed': 0.5} - }, - 'http2': { - 'init': {'connection': 0.95, 'closed': 0.05}, - 'connection': { - 'settings': 0.9, - 'closed': 0.1 - }, - 'settings': { - 'stream': 0.85, - 'settings': 0.1, - 'closed': 0.05 - }, - 'stream': { - 'stream': 0.7, - 'data': 0.25, - 'push': 0.03, - 'closing': 0.01, - 'closed': 0.01 - }, - 'data': { - 'data': 0.8, - 'stream': 0.15, - 'closing': 0.04, - 'closed': 0.01 - }, - 'push': { - 'data': 0.7, - 'stream': 0.25, - 'closing': 0.04, - 'closed': 0.01 - }, - 'closing': {'closed': 0.9, 'stream': 0.1}, - 'closed': {'init': 0.6, 'closed': 0.4} - }, - 'http3': { - 'init': {'quic_handshake': 0.95, 'closed': 0.05}, - 'quic_handshake': { - 'settings': 0.85, - 'quic_handshake': 0.1, - 'closed': 0.05 - }, - 'settings': { - 'stream': 0.8, - 'settings': 0.15, - 'closed': 0.05 - }, - 'stream': { - 'stream': 0.6, - 'data': 0.35, - 'closing': 0.04, - 'closed': 0.01 - }, - 'data': { - 'data': 0.75, - 'stream': 0.2, - 'closing': 0.04, - 'closed': 0.01 - }, - 'closing': {'closed': 0.95, 'stream': 0.05}, - 'closed': {'init': 0.7, 'closed': 0.3} - }, - 'generic': { - 'init': {'connecting': 0.9, 'idle': 0.05, 'closed': 0.05}, - 'connecting': { - 'active': 0.85, - 'idle': 0.1, - 'closed': 0.05 - }, - 'active': { - 'active': 0.75, - 'burst': 0.15, - 'idle': 0.08, - 'closing': 0.01, - 'closed': 0.01 - }, - 'burst': { - 'active': 0.7, - 'burst': 0.2, - 'idle': 0.08, - 'closing': 0.01, - 'closed': 0.01 - }, - 'idle': { - 'active': 0.6, - 'idle': 0.35, - 'closing': 0.03, - 'closed': 0.02 - }, - 'closing': {'closed': 0.9, 'active': 0.1}, - 'closed': {'init': 0.5, 'closed': 0.5} - } - } - - return transitions.get(protocol, transitions['generic']) - - def _build_characteristics(self, protocol: str) -> Dict[str, Dict[str, Any]]: - """ - Build state characteristics for the protocol. - - Returns: - Dictionary mapping states to their characteristics - """ - characteristics = { - 'tls': { - 'init': { - 'size_range': (0, 0), - 'interval': 0.0, - 'burst': False, - 'bidirectional': False - }, - 'handshake': { - 'size_range': (100, 2000), - 'interval': 0.005, - 'burst': True, - 'bidirectional': True, - 'pattern': 'request_response' - }, - 'data': { - 'size_range': (64, 16384), - 'interval': 0.02, - 'burst': False, - 'bidirectional': True, - 'pattern': 'stream' - }, - 'closing': { - 'size_range': (21, 31), # Alert message - 'interval': 0.001, - 'burst': False, - 'bidirectional': True - }, - 'closed': { - 'size_range': (0, 0), - 'interval': 1.0, - 'burst': False, - 'bidirectional': False - } - }, - 'quic': { - 'initial': { - 'size_range': (1200, 1400), - 'interval': 0.001, - 'burst': True, - 'bidirectional': True, - 'pattern': 'datagram' - }, - 'handshake': { - 'size_range': (500, 1400), - 'interval': 0.003, - 'burst': True, - 'bidirectional': True, - 'pattern': 'request_response' - }, - 'application': { - 'size_range': (100, 1400), - 'interval': 0.015, - 'burst': False, - 'bidirectional': True, - 'pattern': 'stream' - }, - 'retry': { - 'size_range': (100, 200), - 'interval': 0.1, - 'burst': False, - 'bidirectional': False - } - }, - 'webrtc': { - 'stun': { - 'size_range': (20, 200), - 'interval': 0.1, - 'burst': False, - 'bidirectional': True, - 'pattern': 'request_response' - }, - 'turn': { - 'size_range': (50, 300), - 'interval': 0.05, - 'burst': False, - 'bidirectional': True, - 'pattern': 'request_response' - }, - 'dtls': { - 'size_range': (100, 1000), - 'interval': 0.01, - 'burst': True, - 'bidirectional': True, - 'pattern': 'handshake' - }, - 'srtp': { - 'size_range': (100, 200), - 'interval': 0.02, - 'burst': False, - 'bidirectional': True, - 'pattern': 'rtp_stream' - }, - 'rtcp': { - 'size_range': (70, 90), - 'interval': 1.0, - 'burst': False, - 'bidirectional': True, - 'pattern': 'periodic' - } - }, - 'ssh': { - 'handshake': { - 'size_range': (50, 500), - 'interval': 0.01, - 'burst': True, - 'bidirectional': True, - 'pattern': 'negotiation' - }, - 'auth': { - 'size_range': (100, 1000), - 'interval': 0.02, - 'burst': False, - 'bidirectional': True, - 'pattern': 'challenge_response' - }, - 'session': { - 'size_range': (32, 1400), - 'interval': 0.05, - 'burst': False, - 'bidirectional': True, - 'pattern': 'interactive' - }, - 'channel': { - 'size_range': (100, 1400), - 'interval': 0.02, - 'burst': True, - 'bidirectional': True, - 'pattern': 'multiplexed' - } - }, - 'generic': { - 'connecting': { - 'size_range': (64, 500), - 'interval': 0.01, - 'burst': True, - 'bidirectional': True - }, - 'active': { - 'size_range': (200, 1200), - 'interval': 0.02, - 'burst': False, - 'bidirectional': True - }, - 'burst': { - 'size_range': (1000, 1400), - 'interval': 0.001, - 'burst': True, - 'bidirectional': False - }, - 'idle': { - 'size_range': (64, 128), - 'interval': 1.0, - 'burst': False, - 'bidirectional': False - } - } - } - - # Get protocol-specific or default to generic - proto_chars = characteristics.get(protocol, characteristics['generic']) - - # Add default values for any missing states - default_char = { - 'size_range': (64, 1400), - 'interval': 0.1, - 'burst': False, - 'bidirectional': True - } - - return defaultdict(lambda: default_char, proto_chars) - - def next_state(self) -> str: - """ - Transition to the next state based on probabilities. - - Returns: - New state name - """ - # Record state change time - self.last_state_change = time.time() - - # Get transitions for current state - if self.current_state not in self.state_transitions: - self.current_state = 'init' - - transitions = self.state_transitions[self.current_state] - - # Apply protocol-specific logic - transitions = self._apply_protocol_logic(transitions) - - # Choose next state - states = list(transitions.keys()) - weights = list(transitions.values()) - - # Normalize weights if needed - weight_sum = sum(weights) - if weight_sum > 0: - weights = [w / weight_sum for w in weights] - else: - weights = [1.0 / len(states)] * len(states) - - self.current_state = random.choices(states, weights=weights)[0] - - # Update tracking - self.state_history.append({ - 'state': self.current_state, - 'timestamp': time.time() - }) - self.state_counts[self.current_state] += 1 - - # Update protocol state - self._update_protocol_state() - - return self.current_state - - def _apply_protocol_logic(self, transitions: Dict[str, float]) -> Dict[str, float]: - """ - Apply protocol-specific logic to modify transition probabilities. - - Args: - transitions: Base transition probabilities - - Returns: - Modified transition probabilities - """ - modified = dict(transitions) - - # Connection age effects - connection_age = time.time() - self.connection_start - - if self.protocol == 'tls': - # TLS renegotiation - if self.current_state == 'data' and connection_age > 300: # 5 minutes - modified['handshake'] = 0.1 # Renegotiation probability - - elif self.protocol == 'quic': - # QUIC connection migration - if self.current_state == 'application' and random.random() < 0.001: - modified['handshake'] = 0.05 - - elif self.protocol == 'webrtc': - # ICE restart - if self.current_state == 'srtp' and connection_age > 600: # 10 minutes - modified['stun'] = 0.02 - - elif self.protocol == 'ssh': - # SSH rekeying - if self.current_state == 'session' and self.data_transferred > 1024 * 1024 * 1024: # 1GB - modified['handshake'] = 0.1 - - return modified - - def _update_protocol_state(self): - """Update protocol-specific state variables.""" - if self.current_state in ['handshake', 'auth', 'dtls']: - self.handshake_complete = False - elif self.current_state in ['data', 'application', 'session', 'srtp']: - self.handshake_complete = True - - # Estimate data transfer - if self.current_state in ['data', 'application', 'session', 'srtp', 'stream']: - char = self.get_state_characteristics() - avg_size = sum(char['size_range']) / 2 - self.data_transferred += avg_size - - def get_state_characteristics(self) -> Dict[str, Any]: - """ - Get characteristics for the current state. - - Returns: - Dictionary of state characteristics - """ - return self.state_characteristics[self.current_state].copy() - - def generate_packet_params(self) -> Tuple[int, float]: - """ - Generate packet parameters based on current state. - - Returns: - Tuple of (packet_size, interval) - """ - char = self.get_state_characteristics() - - # Generate size - min_size, max_size = char['size_range'] - if char.get('burst', False): - # Burst mode - bias towards larger packets - size = int(random.triangular(min_size, max_size, max_size)) - else: - # Normal distribution - size = random.randint(min_size, max_size) - - # Generate interval - base_interval = char['interval'] - - # Apply pattern-specific timing - pattern = char.get('pattern', 'default') - - if pattern == 'request_response': - # Alternating fast/slow - if self.state_counts[self.current_state] % 2 == 0: - interval = base_interval * 0.1 - else: - interval = base_interval * 2.0 - - elif pattern == 'stream': - # Steady stream with occasional variations - interval = base_interval * random.uniform(0.8, 1.2) - - elif pattern == 'rtp_stream': - # Very regular timing - interval = base_interval * random.uniform(0.98, 1.02) - - elif pattern == 'periodic': - # Fixed intervals - interval = base_interval - - elif pattern == 'interactive': - # Human-like delays - interval = base_interval * random.lognormvariate(0, 0.5) - interval = max(0.001, min(5.0, interval)) - - else: - # Default with jitter - interval = base_interval * random.uniform(0.5, 1.5) - - # Apply RTT effects - if char.get('bidirectional', False): - interval = max(interval, self.rtt_estimate * 0.5) - - return size, interval - - def should_terminate(self) -> bool: - """ - Determine if the connection should terminate. - - Returns: - True if connection should terminate - """ - # Check if in closed state - if self.current_state == 'closed': - return random.random() < 0.5 - - # Age-based termination - connection_age = time.time() - self.connection_start - - # Protocol-specific lifetimes - max_lifetimes = { - 'tls': 3600, # 1 hour - 'quic': 7200, # 2 hours - 'webrtc': 10800, # 3 hours - 'ssh': 14400, # 4 hours - 'http2': 1800, # 30 minutes - 'http3': 1800, # 30 minutes - 'generic': 3600 # 1 hour - } - - max_lifetime = max_lifetimes.get(self.protocol, 3600) - - if connection_age > max_lifetime: - return random.random() < 0.1 # 10% chance per check - - # Data-based termination - if self.data_transferred > 10 * 1024 * 1024 * 1024: # 10GB - return random.random() < 0.05 - - return False - - def get_session_phase(self) -> str: - """ - Get the current session phase. - - Returns: - Phase name (startup, active, closing, closed) - """ - handshake_states = ['init', 'handshake', 'auth', 'stun', 'turn', 'dtls', - 'initial', 'connection', 'settings', 'quic_handshake'] - active_states = ['data', 'application', 'session', 'srtp', 'stream', - 'active', 'burst', 'channel'] - closing_states = ['closing'] - closed_states = ['closed'] - - if self.current_state in handshake_states: - return 'startup' - elif self.current_state in active_states: - return 'active' - elif self.current_state in closing_states: - return 'closing' - elif self.current_state in closed_states: - return 'closed' - else: - return 'unknown' - - def simulate_packet_loss(self, loss_rate: float = 0.001) -> bool: - """ - Simulate packet loss for the current state. - - Args: - loss_rate: Base packet loss rate - - Returns: - True if packet should be dropped - """ - # Adjust loss rate based on state - if self.current_state in ['handshake', 'auth', 'initial']: - # Lower loss during critical phases - adjusted_rate = loss_rate * 0.5 - elif self.current_state in ['closing', 'closed']: - # Higher loss during closing - adjusted_rate = loss_rate * 2.0 - else: - adjusted_rate = loss_rate - - return random.random() < adjusted_rate - - def get_statistics(self) -> Dict[str, Any]: - """ - Get state machine statistics. - - Returns: - Dictionary of statistics - """ - stats = { - 'protocol': self.protocol, - 'current_state': self.current_state, - 'session_id': self.session_id, - 'handshake_complete': self.handshake_complete, - 'data_transferred': self.data_transferred, - 'data_transferred_mb': self.data_transferred / (1024 * 1024), - 'connection_age': time.time() - self.connection_start, - 'time_in_current_state': time.time() - self.last_state_change, - 'session_phase': self.get_session_phase(), - 'state_counts': dict(self.state_counts), - 'total_state_changes': sum(self.state_counts.values()), - 'rtt_estimate': self.rtt_estimate, - 'congestion_window': self.congestion_window - } - - # Add state distribution - total_changes = stats['total_state_changes'] - if total_changes > 0: - stats['state_distribution'] = { - state: count / total_changes - for state, count in self.state_counts.items() - } - - # Add recent history - if self.state_history: - recent = list(self.state_history)[-10:] - stats['recent_states'] = [h['state'] for h in recent] - - return stats - - def reset(self): - """Reset state machine to initial state.""" - self.current_state = 'init' - self.state_history.clear() - self.state_timers.clear() - self.state_counts.clear() - self.handshake_complete = False - self.data_transferred = 0 - self.connection_start = time.time() - self.last_state_change = time.time() - self.session_id = random.randint(0, 2**32 - 1) diff --git a/traffic-masking/enhanced/timing.py b/traffic-masking/enhanced/timing.py deleted file mode 100644 index 5447505..0000000 --- a/traffic-masking/enhanced/timing.py +++ /dev/null @@ -1,311 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Copyright © 2025 kogeler -# SPDX-License-Identifier: Apache-2.0 - -""" -Adaptive timing model for realistic network delay simulation -""" - -import random -from collections import deque -from typing import Optional, Dict, Any - - -class AdaptiveTimingModel: - """ - Adaptive timing model based on realistic network behavior. - Simulates network conditions including congestion, jitter, packet loss, and retransmission. - """ - - def __init__(self, base_rtt: float = 0.02, jitter_factor: float = 0.3): - """ - Initialize timing model. - - Args: - base_rtt: Base round-trip time in seconds - jitter_factor: Factor for jitter calculation (0.0-1.0) - """ - self.base_rtt = base_rtt - self.jitter_factor = min(1.0, max(0.0, jitter_factor)) - self.history = deque(maxlen=100) - self.congestion_level = 0.0 - self.packet_loss_rate = 0.001 # 0.1% baseline - - # Network state tracking - self.bandwidth_estimate = 10 * 1024 * 1024 # 10 Mbps default - self.queue_depth = 0.0 - self.max_queue_depth = 50 # packets - - # RTT tracking - self.min_rtt = base_rtt - self.max_rtt = base_rtt * 10 - self.smooth_rtt = base_rtt - self.rtt_variance = base_rtt * 0.1 - - # Congestion control state - self.cwnd = 10 # Congestion window - self.ssthresh = 65535 # Slow start threshold - self.in_slow_start = True - - # Statistics - self.total_packets = 0 - self.lost_packets = 0 - self.retransmitted_packets = 0 - - def get_delay(self, packet_size: int, network_load: float = 0.5) -> float: - """ - Calculate realistic delay considering packet size and network load. - - Args: - packet_size: Size of the packet in bytes - network_load: Current network load (0.0-1.0) - - Returns: - Delay in seconds - """ - self.total_packets += 1 - network_load = min(1.0, max(0.0, network_load)) - - # Calculate transmission delay based on packet size - transmission_delay = packet_size / self.bandwidth_estimate - - # Simulate network congestion with realistic dynamics - self._update_congestion(network_load) - - # Calculate propagation delay with congestion - propagation_delay = self.base_rtt * (1.0 + self.congestion_level * 3.0) - - # Calculate queueing delay using M/M/1 queue model - queue_delay = self._calculate_queue_delay(network_load) - - # Calculate jitter with temporal correlation - jitter = self._calculate_correlated_jitter() - - # Simulate packet loss and retransmission - if self._should_drop_packet(network_load): - self.lost_packets += 1 - self.retransmitted_packets += 1 - # Retransmission timeout (RTO) calculation - rto = self._calculate_rto() - return rto - - # Total delay calculation - total_delay = transmission_delay + propagation_delay + queue_delay + jitter - - # Update RTT estimates - self._update_rtt_estimate(total_delay) - - return max(0.0001, total_delay) - - def _update_congestion(self, network_load: float): - """Update congestion level with realistic network dynamics.""" - # Random congestion events - if random.random() < 0.05: # 5% chance of congestion spike - congestion_spike = random.uniform(0.1, 0.3) * network_load - self.congestion_level = min(1.0, self.congestion_level + congestion_spike) - else: - # Gradual congestion recovery - recovery_rate = 0.01 * (1.0 - network_load) - self.congestion_level = max(0.0, self.congestion_level - recovery_rate) - - # Update congestion window (TCP-like behavior) - if self.congestion_level > 0.5: - # Congestion detected, reduce window - self.cwnd = max(1, self.cwnd // 2) - self.ssthresh = self.cwnd - self.in_slow_start = False - elif self.in_slow_start: - # Slow start phase - self.cwnd = min(self.cwnd + 1, self.ssthresh) - if self.cwnd >= self.ssthresh: - self.in_slow_start = False - else: - # Congestion avoidance - self.cwnd += 1.0 / self.cwnd - - def _calculate_queue_delay(self, network_load: float) -> float: - """ - Calculate queuing delay using M/M/1 queue model. - - Args: - network_load: Current network utilization (0.0-1.0) - - Returns: - Queue delay in seconds - """ - # Update queue depth based on load - arrival_rate = network_load * 100 # packets per second - service_rate = 100 # packets per second capacity - - if arrival_rate < service_rate: - # M/M/1 queue average delay - utilization = arrival_rate / service_rate - avg_queue_size = utilization / (1.0 - utilization) - self.queue_depth = min(self.max_queue_depth, avg_queue_size) - else: - # Queue overflow scenario - self.queue_depth = self.max_queue_depth - - # Queue delay based on Little's Law - queue_delay = (self.queue_depth / service_rate) * random.uniform(0.5, 1.5) - - return queue_delay - - def _calculate_correlated_jitter(self) -> float: - """ - Calculate jitter with temporal correlation for realistic behavior. - - Returns: - Jitter value in seconds - """ - if self.history: - # Use exponentially weighted moving average for correlation - prev_jitter = self.history[-1] - correlation_factor = 0.7 # 70% correlation with previous value - - # New jitter component - new_jitter = random.gauss(0, self.base_rtt * self.jitter_factor) - - # Combine with correlation - jitter = prev_jitter * correlation_factor + new_jitter * (1.0 - correlation_factor) - - # Add occasional jitter spikes (network events) - if random.random() < 0.02: # 2% chance of spike - spike = random.uniform(2, 5) * self.base_rtt * self.jitter_factor - jitter += spike * random.choice([-1, 1]) - else: - jitter = random.gauss(0, self.base_rtt * self.jitter_factor) - - self.history.append(jitter) - return jitter - - def _should_drop_packet(self, network_load: float) -> bool: - """ - Determine if packet should be dropped based on network conditions. - - Args: - network_load: Current network load (0.0-1.0) - - Returns: - True if packet should be dropped - """ - # Calculate dynamic loss rate based on load and congestion - base_loss = self.packet_loss_rate - - # Increase loss rate with congestion - congestion_loss = self.congestion_level * 0.05 # Up to 5% additional loss - - # Load-dependent loss (queue overflow) - if network_load > 0.9: - load_loss = (network_load - 0.9) * 0.1 # Up to 1% additional - else: - load_loss = 0.0 - - # Burst loss simulation - burst_loss = 0.0 - if random.random() < 0.001: # 0.1% chance of burst loss event - burst_loss = 0.1 # 10% loss during burst - - total_loss_rate = min(0.2, base_loss + congestion_loss + load_loss + burst_loss) - - return random.random() < total_loss_rate - - def _calculate_rto(self) -> float: - """ - Calculate retransmission timeout using TCP-like algorithm. - - Returns: - RTO in seconds - """ - # Jacobson's algorithm for RTO calculation - rto = self.smooth_rtt + 4 * self.rtt_variance - - # Apply backoff for multiple retransmissions - backoff_factor = min(64, 2 ** (self.retransmitted_packets % 6)) - rto *= backoff_factor - - # Bound RTO - min_rto = self.base_rtt * 2 - max_rto = 60.0 # 60 seconds max - - return min(max_rto, max(min_rto, rto)) - - def _update_rtt_estimate(self, measured_rtt: float): - """ - Update RTT estimates using exponentially weighted moving average. - - Args: - measured_rtt: Measured round-trip time - """ - alpha = 0.125 # TCP standard - beta = 0.25 # TCP standard - - # Update smooth RTT - self.smooth_rtt = (1 - alpha) * self.smooth_rtt + alpha * measured_rtt - - # Update RTT variance - deviation = abs(measured_rtt - self.smooth_rtt) - self.rtt_variance = (1 - beta) * self.rtt_variance + beta * deviation - - # Track min/max - self.min_rtt = min(self.min_rtt, measured_rtt) - self.max_rtt = max(self.max_rtt, measured_rtt) - - def update_network_conditions(self, rtt_sample: Optional[float] = None, - bandwidth_sample: Optional[float] = None, - loss_rate_sample: Optional[float] = None): - """ - Update model based on observed network conditions. - - Args: - rtt_sample: Observed RTT in seconds - bandwidth_sample: Observed bandwidth in bytes/second - loss_rate_sample: Observed packet loss rate (0.0-1.0) - """ - if rtt_sample is not None and rtt_sample > 0: - # Exponential moving average update - alpha = 0.2 - self.base_rtt = self.base_rtt * (1 - alpha) + rtt_sample * alpha - self._update_rtt_estimate(rtt_sample) - - if bandwidth_sample is not None and bandwidth_sample > 0: - # Update bandwidth estimate - alpha = 0.1 - self.bandwidth_estimate = self.bandwidth_estimate * (1 - alpha) + bandwidth_sample * alpha - - if loss_rate_sample is not None: - # Update loss rate - alpha = 0.1 - self.packet_loss_rate = self.packet_loss_rate * (1 - alpha) + loss_rate_sample * alpha - - def get_statistics(self) -> Dict[str, Any]: - """ - Get current timing model statistics. - - Returns: - Dictionary with statistics - """ - return { - 'base_rtt': self.base_rtt, - 'smooth_rtt': self.smooth_rtt, - 'min_rtt': self.min_rtt, - 'max_rtt': self.max_rtt, - 'rtt_variance': self.rtt_variance, - 'congestion_level': self.congestion_level, - 'packet_loss_rate': self.packet_loss_rate, - 'total_packets': self.total_packets, - 'lost_packets': self.lost_packets, - 'retransmitted_packets': self.retransmitted_packets, - 'loss_percentage': (self.lost_packets / max(1, self.total_packets)) * 100, - 'bandwidth_estimate_mbps': self.bandwidth_estimate * 8 / (1024 * 1024), - 'cwnd': self.cwnd, - 'queue_depth': self.queue_depth - } - - def reset_statistics(self): - """Reset packet statistics while keeping network state.""" - self.total_packets = 0 - self.lost_packets = 0 - self.retransmitted_packets = 0 diff --git a/traffic-masking/masking_lib.py b/traffic-masking/masking_lib.py index 6fe9745..f378730 100644 --- a/traffic-masking/masking_lib.py +++ b/traffic-masking/masking_lib.py @@ -4,48 +4,19 @@ # Copyright © 2025 kogeler # SPDX-License-Identifier: Apache-2.0 -""" -Enhanced masking library for server and client with advanced obfuscation features - -Provides: -- Protocol mimicry profiles (web, video, voip, file-transfer, gaming, mixed) -- Dynamic obfuscation (padding, timing jitter, fragmentation, pseudo-headers) -- Statistical analysis (entropy estimation, periodicity hints) -- Enhanced features when available (adaptive timing, correlation breaking, ML resistance) -- Common utilities for server/client -""" +"""Traffic shaping, padding, packetization, and pacing primitives.""" from __future__ import annotations import os -import struct +import math import random import socket import threading import time -import math -from collections.abc import Callable, Iterator, Sequence from dataclasses import dataclass from enum import Enum -from typing import Any, Dict, List, Optional, Tuple, Union - - -try: - import numpy as np -except Exception: - np = None # Minimal fallback - -# Try to import enhanced modules -try: - from enhanced.timing import AdaptiveTimingModel - from enhanced.correlation import CorrelationBreaker - from enhanced.ml_resistance import MLResistantGenerator - from enhanced.entropy import EntropyEnhancer - from enhanced.state_machine import ProtocolStateMachine # noqa: F401 (availability probe) - ENHANCED_AVAILABLE = True -except ImportError: - ENHANCED_AVAILABLE = False - # Fallback implementations will be provided +from typing import List, Optional __all__ = [ "TrafficProfile", @@ -57,15 +28,9 @@ "RateLimiter", "RateReservation", "ProtocolMimicry", - "DynamicObfuscator", - "StatisticalAnalyzer", - "stream_generator", + "PayloadPadder", "profile_event_generator", - "ObfuscationConfig", - "parse_profile", - "build_obfuscator", "init_udp_socket", - "send_fragments", "mbps_to_bytes_per_second", "generate_payload", ] @@ -90,13 +55,13 @@ class TrafficProfile(Enum): @dataclass(frozen=True) class PatternStep: - size: int # bytes (payload before obfuscation) + size: int # logical payload bytes before padding delay: float # seconds (inter-packet delay target) @dataclass(frozen=True) class ShapeEvent: - """One logical offered-load event before obfuscation and packetization.""" + """One logical offered-load event before padding and packetization.""" byte_count: int delay: float = 0.0 @@ -410,17 +375,6 @@ def refund(self, reservation): class ProtocolMimicry: """Generate sequences of PatternStep for different protocol-like behaviors.""" - def __init__(self): - """Initialize with enhanced features if available.""" - self.enhanced = ENHANCED_AVAILABLE - if self.enhanced: - try: - self.correlation_breaker = CorrelationBreaker() - self.ml_resistant = MLResistantGenerator() - self.timing_model = AdaptiveTimingModel() - except Exception: - self.enhanced = False - @staticmethod def web_browsing_session(rng=None) -> List[PatternStep]: rng = rng or random @@ -529,233 +483,66 @@ def for_profile(profile: TrafficProfile, rng=None) -> List[PatternStep]: }[profile](rng=rng) -class DynamicObfuscator: - """ - Obfuscates packets by: - - Padding - - Pseudo-headers (RTP/QUIC-like) - - Timing jitter - - MTU fragmentation - """ +class PayloadPadder: + """Add observable payload volume before application packetization.""" + + STRATEGIES = {"none", "random", "fixed_buckets", "progressive"} def __init__( self, - padding_strategy: str = "random", # random | fixed_buckets | progressive | none - timing_jitter: float = 0.002, # seconds stddev for jitter - mtu: int = 1200, - header_mode: str = "none", # none | rtp | quic - fixed_buckets: Optional[Sequence[int]] = None, + strategy="none", + ceiling=1200, + fixed_buckets=None, rng=None, byte_source=None, ): - self.padding_strategy = padding_strategy - self.timing_jitter = max(0.0, float(timing_jitter)) - self.mtu = int(mtu) - if self.mtu <= 0: - raise ValueError("mtu must be positive") - self.header_mode = header_mode - self.fixed_buckets = tuple(fixed_buckets) if fixed_buckets else (128, 256, 512, 1024, 1280, 1400) + if strategy not in self.STRATEGIES: + raise ValueError(f"unknown padding strategy: {strategy}") + if isinstance(ceiling, bool) or not isinstance(ceiling, int) or ceiling <= 0: + raise ValueError("padding ceiling must be a positive integer") + self.strategy = strategy + self.ceiling = ceiling + self.fixed_buckets = tuple( + fixed_buckets or (128, 256, 512, 1024, 1280, 1400) + ) self._rng = rng or random.Random() self._byte_source = byte_source or os.urandom - # RTP-like state - self._rtp_seq = self._rng.randint(0, 65535) - self._rtp_ssrc = self._rng.getrandbits(32) - self._rtp_ts_base = self._rng.getrandbits(32) - # QUIC-like PN - self._quic_pn = self._rng.randint(0, 2**32 - 1) - - # Enhanced features if available - self.enhanced = ENHANCED_AVAILABLE - if self.enhanced: - try: - self.timing_model = AdaptiveTimingModel() - self.entropy_enhancer = EntropyEnhancer() - except Exception: - self.enhanced = False - - def obfuscate(self, payload: bytes, profile: Optional[TrafficProfile] = None, base_delay: float = 0.0) -> Tuple[List[bytes], float]: - pkt = self.transform(payload, profile) - fragments = self._fragment(pkt, self.mtu) - - # Compatibility API: preserve the caller's delay and only add bounded jitter. - if base_delay > 0: - jitter = self._rng.gauss( - 0.0, min(self.timing_jitter, base_delay * 0.1) - ) - delay = max(0.0, base_delay + jitter) - else: - delay = 0.0 - - return fragments, delay - - def transform(self, payload, profile=None): - """Apply optional pseudo-header and padding without packetizing.""" + + def transform(self, payload): if not isinstance(payload, (bytes, bytearray, memoryview)): raise ValueError("payload must be bytes") - packet = self._apply_header(bytes(payload), profile) - return self._apply_padding(packet, profile) - - def _apply_padding(self, packet: bytes, profile: Optional[TrafficProfile]) -> bytes: - if self.padding_strategy == "none": - return packet - if self.padding_strategy == "random": - max_pad = max(16, min(120, int(len(packet) * 0.07))) - pad_len = self._rng.randint(0, max_pad) - return packet + self._byte_source(pad_len) - if self.padding_strategy == "progressive": - factor = self._rng.uniform(0.0, 0.20) - pad_len = int(len(packet) * factor) - if pad_len <= 0: - return packet - return packet + self._byte_source(pad_len) - if self.padding_strategy == "fixed_buckets": - target = None - for b in self.fixed_buckets: - if len(packet) <= b: - target = b - break - if target is None: - target = min(max(self.fixed_buckets), self.mtu) - pad_len = max(0, target - len(packet)) - if pad_len == 0: - return packet - return packet + self._byte_source(pad_len) - return packet - - def _apply_header(self, payload: bytes, profile: Optional[TrafficProfile]) -> bytes: - if self.header_mode == "none": + payload = bytes(payload) + if self.strategy == "none": return payload - if self.header_mode == "rtp": - return self._rtp_like(payload, profile) - if self.header_mode == "quic": - return self._quic_like(payload) - return payload - - def _rtp_like(self, payload: bytes, profile: Optional[TrafficProfile]) -> bytes: - # Very rough RTP-like header (12 bytes) - version, padding, extension, csrc_count = 2, 0, 0, 0 - marker = 1 if self._rng.random() < 0.02 else 0 - payload_type = { - TrafficProfile.VOIP_CALL: 111, - TrafficProfile.VIDEO_STREAMING: 96, - }.get(profile, self._rng.randint(96, 127)) - b0 = (version << 6) | (padding << 5) | (extension << 4) | (csrc_count & 0x0F) - b1 = ((marker & 0x01) << 7) | (payload_type & 0x7F) - self._rtp_seq = (self._rtp_seq + 1) & 0xFFFF - ts_step = self._rng.randint(800, 2000) - self._rtp_ts_base = (self._rtp_ts_base + ts_step) & 0xFFFFFFFF - header = struct.pack("!BBHII", b0, b1, self._rtp_seq, self._rtp_ts_base, self._rtp_ssrc) - return header + payload - - def _quic_like(self, payload: bytes) -> bytes: - flags = 0xC0 | (self._rng.randint(0, 3) << 4) - dcid_len = self._rng.choice([8, 12, 16]) - scid_len = self._rng.choice([0, 8, 12]) - dcid = self._byte_source(dcid_len) - scid = self._byte_source(scid_len) - pn_len = self._rng.choice([1, 2, 3, 4]) - self._quic_pn = (self._quic_pn + 1) & 0xFFFFFFFF - pn_mask = (1 << (pn_len * 8)) - 1 - pn_val = self._quic_pn & pn_mask - pn_bytes = pn_val.to_bytes(pn_len, "big") - header = bytes([flags, dcid_len]) + dcid + bytes([scid_len]) + scid + pn_bytes - return header + payload - - @staticmethod - def fragment(packet: bytes, mtu: int) -> List[bytes]: - mtu = int(mtu) - if mtu <= 0: - raise ValueError("mtu must be positive") - frags: List[bytes] = [] - for i in range(0, len(packet), mtu): - frags.append(packet[i : i + mtu]) - return frags - - def _fragment(self, packet: bytes, mtu: int) -> List[bytes]: - return self.fragment(packet, mtu) - - -class StatisticalAnalyzer: - """Simple statistical checks: entropy and periodicity hints.""" + if self.strategy == "random": + max_padding = max(16, min(120, int(len(payload) * 0.07))) + return self._append(payload, self._rng.randint(0, max_padding)) + if self.strategy == "progressive": + return self._append(payload, int(len(payload) * self._rng.uniform(0, 0.2))) + + target = next( + (bucket for bucket in self.fixed_buckets if len(payload) <= bucket), + min(max(self.fixed_buckets), self.ceiling), + ) + return self._append(payload, max(0, target - len(payload))) - @staticmethod - def entropy_bits_per_byte(data: bytes) -> float: - if not data: - return 0.0 - if np is None: - # Crude fallback — return mid-high entropy value - return 7.0 - arr = np.frombuffer(data, dtype=np.uint8) - counts = np.bincount(arr, minlength=256) - p = counts[counts > 0].astype(np.float64) - p /= p.sum() - entropy = float(-np.sum(p * np.log2(p))) - return max(0.0, min(8.0, entropy)) + def _append(self, payload, padding_size): + if padding_size <= 0: + return payload + padding = bytes(self._byte_source(padding_size)) + if len(padding) != padding_size: + raise ValueError("byte source returned the wrong padding length") + return payload + padding - @staticmethod - def entropy_normalized(data: bytes) -> float: - return StatisticalAnalyzer.entropy_bits_per_byte(data) / 8.0 - @staticmethod - def detect_periodicity(packet_sizes: Sequence[int], packet_times: Sequence[float]) -> Dict[str, Any]: - if np is None or len(packet_sizes) < 5 or len(packet_times) < 5: - return {"sizes_cv": None, "intervals_cv": None, "interval_peak_ms": None} - sizes = np.array(packet_sizes, dtype=np.float64) - intervals = np.diff(np.array(packet_times, dtype=np.float64)) - intervals = intervals[intervals > 0] - result: Dict[str, Any] = {"sizes_cv": None, "intervals_cv": None, "interval_peak_ms": None} - if sizes.size > 1: - mean_s = float(np.mean(sizes)) - std_s = float(np.std(sizes)) - result["sizes_cv"] = None if mean_s == 0 else std_s / mean_s - if intervals.size > 1: - mean_i = float(np.mean(intervals)) - std_i = float(np.std(intervals)) - result["intervals_cv"] = None if mean_i == 0 else std_i / mean_i - hist, edges = np.histogram(intervals, bins=20) - peak_idx = int(np.argmax(hist)) - peak_center = (edges[peak_idx] + edges[peak_idx + 1]) / 2.0 - result["interval_peak_ms"] = peak_center * 1000.0 - return result - - -def generate_payload( - size: int, - entropy: float = 1.0, - rng=None, - byte_source=None, -) -> bytes: - rng = rng or random.Random() - byte_source = byte_source or os.urandom - size = max(0, int(size)) - if size == 0: - return b"" - - # For performance, use simple random generation by default - # Enhanced entropy is expensive and should be used sparingly - if ENHANCED_AVAILABLE and size > 1000 and rng.random() < 0.1: # Use enhanced only 10% of time for large packets - try: - enhancer = EntropyEnhancer() - return enhancer.generate_realistic_encrypted_payload(size, content_type='mixed') - except Exception: - pass - - # Fast path for high entropy (most common case) - if entropy >= 0.95: - return byte_source(size) - - # Optimized generation for lower entropy - if entropy < 0.5: - # Low entropy - mostly repeated bytes - base_byte = rng.randint(0, 255) - data = bytearray([base_byte] * size) - # Add some variation - for _ in range(int(size * entropy)): - data[rng.randint(0, size-1)] = rng.randint(0, 255) - return bytes(data) - else: - # Medium to high entropy - mix of random and patterns - return byte_source(size) +def generate_payload(size, byte_source=None): + """Return opaque cover payload bytes from a bulk byte source.""" + if isinstance(size, bool) or not isinstance(size, int) or size < 0: + raise ValueError("payload size must be a non-negative integer") + payload = bytes((byte_source or os.urandom)(size)) + if len(payload) != size: + raise ValueError("byte source returned the wrong payload length") + return payload def profile_event_generator(profile, rng=None): @@ -769,98 +556,8 @@ def profile_event_generator(profile, rng=None): yield ShapeEvent(byte_count=step.size, delay=step.delay) -def stream_generator( - profile: TrafficProfile, - target_mbps: Optional[float] = None, - min_mbps: Optional[float] = None, - max_mbps: Optional[float] = None, - obfuscator: Optional[DynamicObfuscator] = None, - entropy: float = 1.0, - rng=None, -) -> Iterator[Tuple[List[bytes], float]]: - """Compatibility iterator built on unmodified logical profile events.""" - if (min_mbps is None) != (max_mbps is None): - raise ValueError("min_mbps and max_mbps must be given together") - rng = rng or random.Random() - obfuscator = obfuscator or DynamicObfuscator(rng=rng) - events = profile_event_generator(profile, rng=rng) - rate_controlled = target_mbps is not None or min_mbps is not None - - for event in events: - if rate_controlled and event.byte_count == 0: - continue - payload = generate_payload( - event.byte_count, entropy=entropy, rng=rng - ) - fragments, _ = obfuscator.obfuscate(payload, profile=profile) - if min_mbps is not None: - rate_mbps = rng.uniform(min_mbps, max_mbps) - else: - rate_mbps = target_mbps - if rate_mbps is None: - delay = event.delay - else: - delay = sum(len(fragment) for fragment in fragments) / ( - mbps_to_bytes_per_second(rate_mbps) - ) - yield fragments, delay - - -# Common utilities for server/client - -@dataclass -class ObfuscationConfig: - padding_strategy: str = "random" - header_mode: str = "none" - mtu: int = 1200 - entropy: float = 1.0 - timing_jitter: float = 0.002 - - -def parse_profile(profile: Union[str, TrafficProfile, None]) -> TrafficProfile: - if isinstance(profile, TrafficProfile): - return profile - if isinstance(profile, str): - try: - return TrafficProfile(profile) - except Exception: - return TrafficProfile.MIXED - return TrafficProfile.MIXED - - -def build_obfuscator(cfg: ObfuscationConfig, rng=None, byte_source=None) -> DynamicObfuscator: - return DynamicObfuscator( - padding_strategy=cfg.padding_strategy, - timing_jitter=cfg.timing_jitter, - mtu=cfg.mtu, - header_mode=cfg.header_mode, - rng=rng, - byte_source=byte_source, - ) - - def init_udp_socket(sock: socket.socket, sndbuf: int = 4 * 1024 * 1024, rcvbuf: int = 4 * 1024 * 1024) -> socket.socket: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, int(sndbuf)) sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, int(rcvbuf)) return sock - - -def send_fragments( - sock: socket.socket, - addrs: Union[Tuple[str, int], List[Tuple[str, int]]], - fragments: List[bytes], - on_sent: Optional[Callable[[int], None]] = None, -) -> None: - """ - Send fragments to one or many addresses. on_sent(len_bytes) is called per-fragment per-destination. - """ - if isinstance(addrs, tuple): - addrs_list = [addrs] - else: - addrs_list = list(addrs) - for frag in fragments: - for addr in addrs_list: - sock.sendto(frag, addr) - if on_sent: - on_sent(len(frag)) diff --git a/traffic-masking/requirements.txt b/traffic-masking/requirements.txt index b9c1de2..e69de29 100644 --- a/traffic-masking/requirements.txt +++ b/traffic-masking/requirements.txt @@ -1 +0,0 @@ -numpy==2.5.1 diff --git a/traffic-masking/systemd/README.md b/traffic-masking/systemd/README.md index 6e77b4b..9017479 100644 --- a/traffic-masking/systemd/README.md +++ b/traffic-masking/systemd/README.md @@ -1,160 +1,91 @@ -# Systemd Service Units +# Systemd Installation -This directory contains systemd service units for running the Traffic Masking System as system services. +The unit templates run the authenticated UDP server and client. They do not +provide encryption or multiplexing; deploy them only within the intended external +encrypted transport. -## Installation +## Install Files -1. Copy service files to systemd directory: ```bash -sudo cp traffic-masking-*.service /etc/systemd/system/ +sudo install -d -o root -g root -m 0755 /opt/traffic-masking +sudo install -m 0755 ../traffic_masking_server.py /opt/traffic-masking/ +sudo install -m 0755 ../traffic_masking_client.py /opt/traffic-masking/ +sudo install -m 0644 ../control_protocol.py /opt/traffic-masking/ +sudo install -m 0644 ../masking_lib.py /opt/traffic-masking/ + +sudo install -m 0644 traffic-masking-server.service /etc/systemd/system/ +sudo install -m 0644 traffic-masking-client.service /etc/systemd/system/ ``` -2. Create working directory and copy application files: -```bash -sudo mkdir -p /opt/traffic-masking -sudo cp ../*.py /opt/traffic-masking/ -sudo cp -r ../enhanced /opt/traffic-masking/ -sudo chown -R nobody:nogroup /opt/traffic-masking -``` +The runtime uses only the Python standard library. -3. Install Python dependencies: -```bash -sudo python3 -m venv /opt/traffic-masking/venv -sudo /opt/traffic-masking/venv/bin/pip install numpy -``` +## Install The Shared Key -4. Generate the shared key on one endpoint and securely transfer the same binary - file to the other endpoint: +Generate the key on one endpoint, transfer the same binary file securely to the +other endpoint, and restrict it to the service account: ```bash -sudo install -d -o root -g root -m 0755 /etc/traffic-masking umask 077 -openssl rand 32 > /tmp/control.psk -sudo install -o nobody -g nogroup -m 0400 /tmp/control.psk \ - /etc/traffic-masking/control.psk -rm -f /tmp/control.psk +openssl rand 32 > control.psk +sudo install -o nobody -g nogroup -m 0400 control.psk \ + /etc/traffic-masking/control.psk ``` -5. Update service files to use venv Python: -```bash -sudo sed -i 's|/usr/bin/python3|/opt/traffic-masking/venv/bin/python|g' \ - /etc/systemd/system/traffic-masking-*.service -``` +Never place the key value in a unit command or environment variable. -## Configuration +## Configure -### Server Configuration +The server template uses the experimental `mixed` native profile with random +padding and an 80 Mbps aggregate cap. -The server service is configured with authenticated experimental profile shaping: -- Native mixed-profile offered load capped at 10 Mbps -- Profile mode (the cap does not increase native offered load) -- RTP headers and random padding -- Maximum entropy (1.0) +Set the client server address with a drop-in: -To modify server settings, create a drop-in override: ```bash -sudo systemctl edit traffic-masking-server.service +sudo systemctl edit traffic-masking-client.service ``` -Example override to change rate: ```ini [Service] -ExecStart= -ExecStart=/opt/traffic-masking/venv/bin/python /opt/traffic-masking/traffic_masking_server.py \ - --shape-mode profile --max-mbps 5 --profile video \ - --psk-file /etc/traffic-masking/control.psk +Environment="SERVER_IP=192.0.2.10" ``` -### Client Configuration - -The client connects to localhost by default. To connect to a remote server: +Rate-mode server override example: -1. Create drop-in override: ```bash -sudo systemctl edit traffic-masking-client.service +sudo systemctl edit traffic-masking-server.service ``` -2. Add server IP: ```ini [Service] -Environment="SERVER_IP=192.168.1.100" +ExecStart= +ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py --host 0.0.0.0 --port 8888 --shape-mode rate --min-mbps 2 --max-mbps 8 --max-total-mbps 40 --psk-file /etc/traffic-masking/control.psk ``` -## Usage +## Start -### Start Services ```bash -# Server only -sudo systemctl start traffic-masking-server.service - -# Client only -sudo systemctl start traffic-masking-client.service - -# Both -sudo systemctl start traffic-masking-server.service traffic-masking-client.service +sudo systemctl daemon-reload +sudo systemctl enable --now traffic-masking-server.service +sudo systemctl enable --now traffic-masking-client.service ``` -### Enable at Boot -```bash -sudo systemctl enable traffic-masking-server.service -sudo systemctl enable traffic-masking-client.service -``` +Use the server unit on the emitting endpoint and the client unit on receiving +endpoints as appropriate for the deployment. + +## Inspect -### Check Status ```bash sudo systemctl status traffic-masking-server.service sudo systemctl status traffic-masking-client.service -``` - -### View Logs -```bash -# Follow server logs sudo journalctl -u traffic-masking-server.service -f - -# Follow client logs sudo journalctl -u traffic-masking-client.service -f - -# Last 100 lines -sudo journalctl -u traffic-masking-server.service -n 100 ``` -### Stop Services -```bash -sudo systemctl stop traffic-masking-server.service traffic-masking-client.service -``` +Server statistics label total and per-client framed application rates. Client +statistics label client-total receive/transmit rates and the observed uplink +ratio. + +## Rotate The Key -## Security Features - -Both services include security hardening: -- Run as `nobody:nogroup` user -- Private /tmp directory -- Read-only system directories -- No new privileges -- Resource limits -- HMAC-SHA256 authenticated enrollment and session traffic -- A mode `0400` PSK file that is never exposed in process arguments or logs - -### Key Rotation - -The protocol does not support overlapping keys. Stop both services, install a -new mode `0400` key at `/etc/traffic-masking/control.psk` on both endpoints, then -start both services. A client with an old or incorrect key remains unregistered. - -## Troubleshooting - -### Service Won't Start -- Check logs: `sudo journalctl -u traffic-masking-server.service -e` -- Verify Python path: `which python3` -- Check permissions: `ls -la /opt/traffic-masking/` -- Check PSK ownership/mode: `sudo stat /etc/traffic-masking/control.psk` - -### High CPU Usage -- Reduce `--entropy` to 0.5-0.7 -- Use `--padding none` -- Lower max rate in floating mode - -### Connection Issues -- Check firewall: `sudo ufw status` -- Verify server is listening: `sudo ss -ulnp | grep 8888` -- Run an authenticated client and inspect its status output; arbitrary UDP probes - are intentionally ignored. +There is no multi-key grace period. Stop both endpoints, atomically replace the +key file with the same new value and restrictive mode, then restart both units. diff --git a/traffic-masking/systemd/traffic-masking-client.service b/traffic-masking/systemd/traffic-masking-client.service index a26b869..7a57a1f 100644 --- a/traffic-masking/systemd/traffic-masking-client.service +++ b/traffic-masking/systemd/traffic-masking-client.service @@ -17,17 +17,13 @@ Environment="PYTHONUNBUFFERED=1" # Override SERVER_IP with a drop-in file. Environment="SERVER_IP=127.0.0.1" -# Maximum configuration with advanced features +# Client with an explicit uplink response and observable padding ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_client.py \ --server ${SERVER_IP} \ --port 8888 \ --response 0.3 \ - --advanced \ - --uplink-profile mixed \ - --header rtp \ --padding random \ --mtu 1200 \ - --entropy 1.0 \ --stats-interval 10 \ --psk-file /etc/traffic-masking/control.psk diff --git a/traffic-masking/systemd/traffic-masking-server.service b/traffic-masking/systemd/traffic-masking-server.service index fb1fecc..43be042 100644 --- a/traffic-masking/systemd/traffic-masking-server.service +++ b/traffic-masking/systemd/traffic-masking-server.service @@ -22,10 +22,8 @@ ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py \ --max-mbps 10 \ --shape-mode profile \ --profile mixed \ - --header rtp \ --padding random \ --mtu 1200 \ - --entropy 1.0 \ --stats-interval 10 \ --psk-file /etc/traffic-masking/control.psk \ --max-clients 8 \ diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index 8c1b378..2e81734 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -25,7 +25,6 @@ {"max_mbps": 8}, # only one of the pair {"target_mbps": 5, "mtu": 0}, {"target_mbps": 5, "mtu": MIN_CONTROL_MTU - 1}, - {"target_mbps": 5, "entropy": 1.5}, {"target_mbps": 5, "stats_interval": 0}, {"target_mbps": float("nan")}, {"target_mbps": float("inf")}, @@ -52,7 +51,6 @@ def test_server_accepts_valid_floating_config(): [ {"response_ratio": 1.5}, {"response_ratio": -0.1}, - {"entropy": 2.0}, {"mtu": 0}, {"mtu": MIN_CONTROL_MTU - 1}, {"stats_interval": -1}, @@ -219,10 +217,3 @@ def test_profile_mode_has_native_load_and_optional_cap(): assert uncapped.target_mbps is None assert uncapped.max_mbps is None assert capped.configured_max_mbps == 1 - - -def test_advanced_warns_and_translates_to_profile_mode(): - with pytest.warns(FutureWarning, match="shape-mode profile"): - server = MaskingTrafficServer(advanced=True, psk=TEST_PSK) - assert server.shape_mode == "profile" - assert server.profile.value == "mixed" diff --git a/traffic-masking/test_control_protocol.py b/traffic-masking/test_control_protocol.py index 0462511..209f5fa 100644 --- a/traffic-masking/test_control_protocol.py +++ b/traffic-masking/test_control_protocol.py @@ -453,11 +453,8 @@ def test_authenticated_framing_keeps_up_with_configured_rate(): def test_protocol_overhead_is_reserved_from_client_payload_mtu(): - client = AdaptiveTrafficClient( - "server.example", 8888, psk=KEY, advanced=True, mtu=1200 - ) + client = AdaptiveTrafficClient("server.example", 8888, psk=KEY, mtu=1200) assert client.data_payload_ceiling == 1200 - FRAME_OVERHEAD - assert client.obf_cfg.mtu == client.data_payload_ceiling assert MIN_CONTROL_MTU == FRAME_OVERHEAD + COOKIE_SIZE + 16 diff --git a/traffic-masking/test_core.py b/traffic-masking/test_core.py index e16a0c9..2d8401d 100644 --- a/traffic-masking/test_core.py +++ b/traffic-masking/test_core.py @@ -9,53 +9,37 @@ import pytest from masking_lib import ( - DynamicObfuscator, + PayloadPadder, ProtocolMimicry, TrafficProfile, - parse_profile, - stream_generator, + profile_event_generator, ) -def test_parse_profile_known_and_fallback(): - assert parse_profile("mixed") is TrafficProfile.MIXED - assert parse_profile("web") is TrafficProfile.WEB_BROWSING - # Unknown strings fall back to MIXED rather than raising. - assert parse_profile("bogus") is TrafficProfile.MIXED - - @pytest.mark.parametrize("profile", list(TrafficProfile)) def test_for_profile_is_nonempty(profile): steps = ProtocolMimicry.for_profile(profile, rng=random.Random(profile.value)) assert len(steps) > 0 -def test_obfuscator_produces_fragments(): - obf = DynamicObfuscator(rng=random.Random(1)) - fragments, delay = obf.obfuscate(b"test packet data") - assert len(fragments) > 0 - assert delay >= 0 +@pytest.mark.parametrize("strategy", sorted(PayloadPadder.STRATEGIES)) +def test_payload_padder_preserves_payload_and_only_adds_bytes(strategy): + payload = b"test packet data" + padder = PayloadPadder(strategy=strategy, rng=random.Random(1)) + transformed = padder.transform(payload) - -def test_stream_generator_fixed_rate_yields(): - gen = stream_generator( - TrafficProfile.MIXED, target_mbps=1.0, rng=random.Random(2) - ) - fragments, delay = next(gen) - assert len(fragments) > 0 - assert delay > 0 + assert transformed.startswith(payload) + assert len(transformed) >= len(payload) + if strategy == "none": + assert transformed == payload -def test_stream_generator_floating_rate_yields(): - gen = stream_generator( - TrafficProfile.MIXED, - min_mbps=1.0, - max_mbps=5.0, - rng=random.Random(3), +def test_profile_event_generator_yields_native_shape_event(): + event = next( + profile_event_generator(TrafficProfile.WEB_BROWSING, random.Random(3)) ) - fragments, delay = next(gen) - assert len(fragments) > 0 - assert delay > 0 + assert event.byte_count > 0 + assert event.delay > 0 def test_loopback_udp_roundtrip(): diff --git a/traffic-masking/test_imports.py b/traffic-masking/test_imports.py index de35262..d0936d6 100644 --- a/traffic-masking/test_imports.py +++ b/traffic-masking/test_imports.py @@ -1,9 +1,11 @@ # Copyright © 2026 kogeler # SPDX-License-Identifier: Apache-2.0 -"""Import smoke tests for the core and optional enhanced modules.""" +"""Import smoke tests for the dependency-free runtime modules.""" import importlib +import subprocess +import sys import pytest @@ -14,26 +16,21 @@ "traffic_masking_client", ] -ENHANCED_MODULES = [ - "enhanced.timing", - "enhanced.correlation", - "enhanced.ml_resistance", - "enhanced.entropy", - "enhanced.state_machine", -] - - @pytest.mark.parametrize("module", CORE_MODULES) def test_core_module_imports(module): assert importlib.import_module(module) is not None -@pytest.mark.parametrize("module", ENHANCED_MODULES) -def test_enhanced_module_imports(module): - # Enhanced modules are optional at runtime; skip only if the runtime itself - # could not import them (they are present in this repo, so this should pass). - try: - mod = importlib.import_module(module) - except ImportError as exc: # pragma: no cover - only if enhanced/ is stripped - pytest.skip(f"optional module {module} unavailable: {exc}") - assert mod is not None +def test_runtime_imports_do_not_load_numpy(): + modules = ", ".join(CORE_MODULES) + result = subprocess.run( + [ + sys.executable, + "-c", + f"import {modules}; import sys; assert 'numpy' not in sys.modules", + ], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index e31bd27..2daac9f 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -55,7 +55,7 @@ def test_transmission_bidirectional(spawn, start_server, psk_file): CLIENT, [ "--server", "127.0.0.1", "--port", str(port), - "--response", "0.3", "--advanced", "--uplink-profile", "mixed", + "--response", "0.3", "--padding", "random", "--stats-interval", "1", "--psk-file", str(psk_file), ], "client", diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index cf6185f..a22004d 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -4,10 +4,7 @@ # Copyright © 2025 kogeler # SPDX-License-Identifier: Apache-2.0 -""" -Traffic Masking Client - cover traffic client -Receives and generates reverse traffic to create a bidirectional, realistic stream. -""" +"""Authenticated UDP cover-traffic client with optional uplink responses.""" import argparse import math @@ -18,7 +15,6 @@ import threading import time -import numpy as np from control_protocol import ( CLIENT_TO_SERVER, CONTROL_PADDING_MAX, @@ -41,12 +37,10 @@ make_padding, ) from masking_lib import ( - ObfuscationConfig, Packetizer, + PayloadPadder, RatioBudget, - build_obfuscator, init_udp_socket, - parse_profile, ) @@ -62,12 +56,8 @@ def __init__( server_host, server_port, response_ratio=0.0, - advanced=False, - uplink_profile="mixed", - header="none", - padding="random", + padding="none", mtu=1200, - entropy=1.0, stats_interval=5.0, rng=None, byte_source=None, @@ -84,16 +74,13 @@ def __init__( # Validate configuration up front; fail fast on invalid inputs. try: response_ratio = float(response_ratio) - entropy = float(entropy) stats_interval = float(stats_interval) except (TypeError, ValueError): - raise ValueError( - "response, entropy, and stats-interval must be numbers" - ) from None + raise ValueError("response and stats-interval must be numbers") from None if not math.isfinite(response_ratio) or not 0.0 <= response_ratio <= 1.0: raise ValueError("response ratio must be in [0.0, 1.0]") - if not math.isfinite(entropy) or not 0.0 <= entropy <= 1.0: - raise ValueError("entropy must be in [0.0, 1.0]") + if padding not in PayloadPadder.STRATEGIES: + raise ValueError(f"unknown padding strategy: {padding}") original_mtu = mtu if isinstance(original_mtu, bool): raise ValueError("mtu must be a positive integer") @@ -110,11 +97,6 @@ def __init__( f"mtu must be at least {MIN_CONTROL_MTU} bytes " "for authenticated control framing" ) - if advanced and mtu - FRAME_OVERHEAD < 256: - raise ValueError( - f"mtu must be at least {FRAME_OVERHEAD + 256} bytes " - "in advanced mode" - ) if not math.isfinite(stats_interval) or stats_interval <= 0: raise ValueError("stats-interval must be a positive finite number") try: @@ -212,17 +194,12 @@ def __init__( self.handshake_accepted = False self.stats_interval = stats_interval self.uplink_budget = RatioBudget(response_ratio) - # Advanced obfuscation settings - self.advanced = bool(advanced) - self.obf_cfg = ObfuscationConfig( - padding_strategy=padding, - header_mode=header, - mtu=self.data_payload_ceiling, - entropy=entropy, - timing_jitter=0.002, + self.padder = PayloadPadder( + strategy=padding, + ceiling=self.data_payload_ceiling, + rng=self._rng, + byte_source=self._byte_source, ) - self.uplink_profile = parse_profile(uplink_profile) - self.obfuscator = None def _create_socket(self): """Create and configure a new UDP socket""" @@ -472,13 +449,9 @@ def connect(self): ) auth_mode = "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" print(f"[*] Control authentication: {auth_mode}", flush=True) - # Initialize obfuscator in advanced mode - if self.advanced: - self.obfuscator = build_obfuscator( - self.obf_cfg, rng=self._rng, byte_source=self._byte_source - ) + if self.padder.strategy != "none": print( - f"[*] Advanced client mode: uplink_profile={self.uplink_profile.value}, header={self.obf_cfg.header_mode}, padding={self.obf_cfg.padding_strategy}, mtu={self.obf_cfg.mtu}, entropy={self.obf_cfg.entropy}", + f"[*] Uplink padding: {self.padder.strategy} | mtu={self.mtu}", flush=True, ) @@ -523,10 +496,7 @@ def send_packet(self, packet): """Send packet to the server""" sent_bytes = 0 try: - if self.advanced and self.obfuscator is not None: - packet = self.obfuscator.transform( - packet, profile=self.uplink_profile - ) + packet = self.padder.transform(packet) for fragment in self.packetizer.packetize(packet): sent = self._send_session_message(MessageType.DATA, fragment) if not sent: @@ -630,7 +600,11 @@ def stats_loop(self): recv_pps = self.stats["packets_received"] / elapsed send_pps = self.stats["packets_sent"] / elapsed - avg_rate = np.mean(self.rate_window) if self.rate_window else 0 + avg_rate = ( + sum(self.rate_window) / len(self.rate_window) + if self.rate_window + else 0 + ) conn_status = "connected" if self.connected else "disconnected" print( @@ -661,37 +635,14 @@ def main(): help="Uplink response ratio (0.0-1.0); default 0.0 keeps the flow " "download-dominant. Non-zero uplink is an explicit choice.", ) - parser.add_argument( - "--advanced", - action="store_true", - help="Enable advanced obfuscation for uplink packets", - ) - parser.add_argument( - "--uplink-profile", - choices=["web", "video", "voip", "file", "gaming", "mixed"], - default="mixed", - help="Uplink traffic profile for advanced mode", - ) - parser.add_argument( - "--header", - choices=["none", "rtp", "quic"], - default="none", - help="Pseudo-header type for advanced mode", - ) parser.add_argument( "--padding", choices=["random", "fixed_buckets", "progressive", "none"], - default="random", - help="Padding strategy for advanced mode", - ) - parser.add_argument( - "--mtu", type=int, default=1200, help="MTU for fragmentation in advanced mode" + default="none", + help="Observable uplink payload padding (default: none)", ) parser.add_argument( - "--entropy", - type=float, - default=1.0, - help="Payload entropy (0..1) for advanced mode", + "--mtu", type=int, default=1200, help="Maximum application UDP datagram size" ) parser.add_argument( "--stats-interval", @@ -748,12 +699,8 @@ def main(): args.server, args.port, args.response, - advanced=args.advanced, - uplink_profile=args.uplink_profile, - header=args.header, padding=args.padding, mtu=args.mtu, - entropy=args.entropy, stats_interval=args.stats_interval, psk=psk, insecure_diagnostic=args.insecure_diagnostic, diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index 67a1a6f..9a91554 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -4,10 +4,7 @@ # Copyright © 2025 kogeler # SPDX-License-Identifier: Apache-2.0 -""" -Traffic Masking Server - cover traffic generator -Creates a variable, realistic-looking stream to mask media traffic patterns from heuristic analysis on encrypted tunnels. -""" +"""Authenticated UDP cover-traffic server.""" import argparse import hashlib @@ -19,7 +16,6 @@ import struct import threading import time -import warnings from collections import OrderedDict, deque from control_protocol import ( @@ -46,9 +42,9 @@ verify_cookie, ) from masking_lib import ( - DynamicObfuscator, FloatingRate, Packetizer, + PayloadPadder, RateLimiter, ShapeEvent, TrafficProfile, @@ -68,16 +64,6 @@ def _positive_finite_float(value, name): return value -def _unit_interval_float(value, name): - try: - value = float(value) - except (TypeError, ValueError): - raise ValueError(f"{name} must be a number") from None - if not math.isfinite(value) or not 0.0 <= value <= 1.0: - raise ValueError(f"{name} must be in [0.0, 1.0]") - return value - - def _positive_int(value, name): if isinstance(value, bool): raise ValueError(f"{name} must be a positive integer") @@ -97,7 +83,7 @@ def _env_default(name, fallback): class PacketGenerator: - """Packet generator with variable sizes and pseudo-random payload characteristics""" + """Generate opaque rate-mode payloads with variable packet sizes.""" def __init__(self, min_size=28, max_size=1400, rng=None, byte_source=None): self.min_size = min_size @@ -109,7 +95,7 @@ def __init__(self, min_size=28, max_size=1400, rng=None, byte_source=None): def generate_packet(self, target_size=None): """Generate a data packet""" if target_size is None: - # Packet size distribution (simulate realistic traffic) + # Keep rate-mode datagram sizes variable within the configured bounds. weights = [0.1, 0.15, 0.5, 0.15, 0.1] # Favor medium packets sizes = [ self._rng.randint(self.min_size, 200), # Small @@ -150,13 +136,10 @@ def __init__( target_mbps=None, min_mbps=None, max_mbps=None, - advanced=False, profile=None, shape_mode="rate", - header="none", - padding="random", + padding="none", mtu=1200, - entropy=1.0, stats_interval=5.0, psk=None, insecure_diagnostic=False, @@ -174,28 +157,6 @@ def __init__( # Validate the offered-load contract before constructing generators. if shape_mode not in ("rate", "profile"): raise ValueError("shape-mode must be 'rate' or 'profile'") - if advanced: - warnings.warn( - "--advanced is deprecated; use --shape-mode profile", - FutureWarning, - stacklevel=2, - ) - shape_mode = "profile" - profile = profile or "mixed" - if min_mbps is not None: - if max_mbps is None: - raise ValueError("min-mbps and max-mbps must be given together") - warnings.warn( - "--advanced translates the old min/max range to a profile cap", - FutureWarning, - stacklevel=2, - ) - min_mbps = None - if target_mbps is not None: - if max_mbps is None: - max_mbps = target_mbps - target_mbps = None - floating = False if shape_mode == "rate": if profile is not None: @@ -223,6 +184,10 @@ def __init__( raise ValueError("mbps is not valid in profile shape mode") if max_mbps is not None: max_mbps = _positive_finite_float(max_mbps, "max-mbps") + if padding not in PayloadPadder.STRATEGIES: + raise ValueError(f"unknown padding strategy: {padding}") + if shape_mode == "rate" and padding != "none": + raise ValueError("padding is only valid in profile shape mode") mtu = _positive_int(mtu, "mtu") if mtu > MAX_DATAGRAM_SIZE: raise ValueError(f"mtu must not exceed {MAX_DATAGRAM_SIZE}") @@ -231,12 +196,6 @@ def __init__( f"mtu must be at least {MIN_CONTROL_MTU} bytes " "for authenticated control framing" ) - if shape_mode == "profile" and mtu - FRAME_OVERHEAD < 256: - raise ValueError( - f"mtu must be at least {FRAME_OVERHEAD + 256} bytes " - "in profile shape mode" - ) - entropy = _unit_interval_float(entropy, "entropy") stats_interval = _positive_finite_float( stats_interval, "stats-interval" ) @@ -322,7 +281,6 @@ def __init__( "time": stats_started, } self.stats_interval = stats_interval - self.advanced = shape_mode == "profile" # Compatibility attribute. if shape_mode == "profile": try: self.profile = ( @@ -332,10 +290,8 @@ def __init__( raise ValueError(f"unknown traffic profile: {profile}") from None else: self.profile = None - self.header_mode = header self.padding_strategy = padding self.mtu = mtu - self.entropy = entropy self.max_clients = max_clients self.max_total_mbps = max_total_mbps self.max_handshakes_per_second = max_handshakes_per_second @@ -387,8 +343,7 @@ def start(self): print(f"[*] Target throughput: {self.target_mbps} Mbps", flush=True) if self.shape_mode == "profile": print( - f"[*] Profile transform: header={self.header_mode}, " - f"padding={self.padding_strategy}, mtu={self.mtu}", + f"[*] Profile padding: {self.padding_strategy} | mtu={self.mtu}", flush=True, ) auth_mode = "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" @@ -572,14 +527,12 @@ def _new_client_state(self, frame, now, receive_key, send_key): limiter_mbps = ( current_rate_mbps if self.shape_mode == "rate" else self.max_mbps ) - obfuscator = None + padder = None generator = None if self.shape_mode == "profile": - obfuscator = DynamicObfuscator( - padding_strategy=self.padding_strategy, - timing_jitter=0.002, - mtu=self.data_payload_ceiling, - header_mode=self.header_mode, + padder = PayloadPadder( + strategy=self.padding_strategy, + ceiling=self.data_payload_ceiling, rng=client_rng, byte_source=self._byte_source, ) @@ -617,7 +570,7 @@ def _new_client_state(self, frame, now, receive_key, send_key): "floating_rate": floating_rate, "current_rate_mbps": current_rate_mbps, "generator": generator, - "obfuscator": obfuscator, + "padder": padder, "pending_fragments": deque(), "next_event_at": self._monotonic_clock(), "pending_event_delay": 0.0, @@ -769,17 +722,11 @@ def _next_shape_event(self, client): def _make_event_payload(self, client, event): if self.shape_mode == "rate": return client["packet_gen"].generate_packet(event.byte_count) - payload = bytes( - generate_payload( - event.byte_count, - entropy=self.entropy, - rng=client["rng"], - byte_source=self._byte_source, - ) + payload = generate_payload( + event.byte_count, + byte_source=self._byte_source, ) - if len(payload) != event.byte_count: - raise ValueError("byte source returned the wrong event payload length") - return client["obfuscator"].transform(payload, profile=self.profile) + return client["padder"].transform(payload) def _send_fragment(self, addr, client, fragment): framed = self._frame_data_for_client(client, fragment) @@ -916,38 +863,21 @@ def main(): default="rate", help="Offered-load contract (default: rate)", ) - parser.add_argument( - "--advanced", - action="store_true", - help="Deprecated compatibility alias for --shape-mode profile", - ) parser.add_argument( "--profile", choices=["web", "video", "voip", "file", "gaming", "mixed"], default=None, help="Required experimental traffic profile in profile shape mode", ) - parser.add_argument( - "--header", - choices=["none", "rtp", "quic"], - default="none", - help="Pseudo-header type in profile mode", - ) parser.add_argument( "--padding", choices=["random", "fixed_buckets", "progressive", "none"], - default="random", - help="Padding strategy in profile mode", + default="none", + help="Observable payload padding in profile mode (default: none)", ) parser.add_argument( "--mtu", type=int, default=1200, help="Maximum application UDP datagram size" ) - parser.add_argument( - "--entropy", - type=float, - default=1.0, - help="Payload entropy compatibility setting for profile mode", - ) parser.add_argument( "--stats-interval", type=float, @@ -993,13 +923,10 @@ def main(): args.mbps, min_mbps=args.min_mbps, max_mbps=args.max_mbps, - advanced=args.advanced, profile=args.profile, shape_mode=args.shape_mode, - header=args.header, padding=args.padding, mtu=args.mtu, - entropy=args.entropy, stats_interval=args.stats_interval, psk=psk, insecure_diagnostic=args.insecure_diagnostic, From ca6273503f86274a7baebdfdcb8e69442ab436e8 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:34:54 +0300 Subject: [PATCH 08/10] traffic-masking: harden runtime lifecycle and metrics --- traffic-masking/AGENTS.md | 4 + traffic-masking/Dockerfile | 2 +- traffic-masking/Makefile | 2 +- traffic-masking/README.md | 22 +- traffic-masking/SUMMARY.md | 16 + traffic-masking/observer_metrics.py | 312 +++++++++ traffic-masking/systemd/README.md | 23 +- .../systemd/traffic-masking-client.service | 47 +- .../systemd/traffic-masking-server.service | 46 +- traffic-masking/test_cli.py | 20 +- traffic-masking/test_concurrency.py | 98 ++- traffic-masking/test_imports.py | 1 + traffic-masking/test_live.py | 41 +- traffic-masking/test_observer_metrics.py | 116 ++++ traffic-masking/test_uplink.py | 21 +- traffic-masking/traffic_masking_client.py | 494 ++++++++------ traffic-masking/traffic_masking_server.py | 612 +++++++++++------- 17 files changed, 1418 insertions(+), 459 deletions(-) create mode 100644 traffic-masking/observer_metrics.py create mode 100644 traffic-masking/test_observer_metrics.py diff --git a/traffic-masking/AGENTS.md b/traffic-masking/AGENTS.md index daef45c..5f2666d 100644 --- a/traffic-masking/AGENTS.md +++ b/traffic-masking/AGENTS.md @@ -45,6 +45,10 @@ spike, or guarantee a target aggregate when user traffic already exceeds it. - Keep stochastic tests deterministic through injected clocks and RNGs. - Preserve the PSK, anti-amplification, sequence, MTU, per-client, and aggregate cap tests when changing the data path. +- Derive runtime rates from consecutive structured snapshots; do not parse logs + when a snapshot is available to a test. +- Treat observer traces as external measurements with explicit capture point, + connection ID, direction, byte layer, and encapsulation overhead. - Run `make test-fast`, `make lint`, and `make test-live` for changes that affect process or network behavior. - Runtime code must remain importable with only the standard library. diff --git a/traffic-masking/Dockerfile b/traffic-masking/Dockerfile index 2556793..155230b 100644 --- a/traffic-masking/Dockerfile +++ b/traffic-masking/Dockerfile @@ -24,7 +24,7 @@ COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt # Copy application files -COPY control_protocol.py masking_lib.py traffic_masking_server.py traffic_masking_client.py /app/ +COPY control_protocol.py masking_lib.py observer_metrics.py traffic_masking_server.py traffic_masking_client.py /app/ # Copy documentation COPY *.md /app/ diff --git a/traffic-masking/Makefile b/traffic-masking/Makefile index 8baf6f9..1cf8423 100644 --- a/traffic-masking/Makefile +++ b/traffic-masking/Makefile @@ -7,7 +7,7 @@ PIP := $(VENV)/bin/pip RUFF := $(VENV)/bin/ruff DEPS_STAMP := $(VENV)/.deps-installed -COV := --cov=control_protocol --cov=masking_lib \ +COV := --cov=control_protocol --cov=masking_lib --cov=observer_metrics \ --cov=traffic_masking_server --cov=traffic_masking_client \ --cov-branch --cov-report=term-missing diff --git a/traffic-masking/README.md b/traffic-masking/README.md index 9cd5fbb..03868fa 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -116,8 +116,26 @@ Client health timings are configurable with: - `--reconnect-delay-max` / `TRAFFIC_MASKING_RECONNECT_DELAY_MAX` `--stats-interval` or `TRAFFIC_MASKING_STATS_INTERVAL` controls reporting on -either endpoint. CLI values override environment defaults. Server logs label -total and per-client rates separately. +either endpoint. CLI values override environment defaults. Both endpoints report +instantaneous monotonic windows. Server logs label total and per-client rates +separately. + +`MaskingTrafficServer.snapshot()` and `AdaptiveTrafficClient.snapshot()` return +immutable counter/state snapshots for tests and operational integrations. The +process workers are non-daemon threads; SIGINT and SIGTERM close the active socket +and join those workers with a bounded timeout. + +## Observer Metrics + +`observer_metrics.py` defines `ObserverEvent` for captures made at the external +observer boundary. Every event declares timestamp, direction, outer datagram +bytes, connection ID, capture point, and encapsulation overhead. Helpers compute +fixed windows, idle-gap distributions, direction ratios, burst summaries, and +size autocorrelation using either outer bytes or bytes after declared overhead. + +The module analyzes supplied events; it does not capture packets or establish +that an application datagram maps one-to-one to an outer datagram. Operators must +collect the trace at the actual enclosing transport boundary. ## Testing diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index d48a893..65a158c 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -43,6 +43,17 @@ statistically indistinguishable from legitimate traffic. - keepalive, health timeout, and exponential reconnect handling; - monotonic receive-rate windows and client-total metrics. +`observer_metrics.py` provides: + +- a validated external trace event with explicit direction, connection, capture + point, outer datagram size, and encapsulation overhead; +- fixed monotonic windows and idle-gap distributions; +- outer/inner direction ratios, burst summaries, and size autocorrelation. + +The observer module consumes an existing capture. Packet acquisition and mapping +inner application datagrams to outer transport datagrams remain deployment +responsibilities. + ## Shaping Modes In `rate` mode, `--mbps` selects a fixed per-client target. A @@ -63,6 +74,11 @@ handcrafted experimental inputs, not measured baselines. Mbps values are decimal application rates. IP, UDP, and enclosing encrypted transport overhead require a separate observer measurement. +Both processes expose immutable structured snapshots. Human-readable logs derive +instantaneous rates from consecutive snapshots rather than cumulative averages. +Their sockets and session counters are synchronized, and SIGINT/SIGTERM trigger a +bounded join of non-daemon workers. + ## Security Boundary The control protocol prevents arbitrary unauthenticated destinations from being diff --git a/traffic-masking/observer_metrics.py b/traffic-masking/observer_metrics.py new file mode 100644 index 0000000..4989617 --- /dev/null +++ b/traffic-masking/observer_metrics.py @@ -0,0 +1,312 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Metrics for traces captured at a declared external observer boundary.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +UPLINK = "uplink" +DOWNLINK = "downlink" +BYTE_LAYERS = frozenset({"outer", "inner"}) +DIRECTIONS = frozenset({UPLINK, DOWNLINK}) + + +@dataclass(frozen=True, slots=True) +class ObserverEvent: + """One externally observed datagram with explicit capture semantics.""" + + timestamp: float + direction: str + outer_datagram_bytes: int + connection_id: str + capture_point: str + encapsulation_overhead: int = 0 + + def __post_init__(self): + if ( + isinstance(self.timestamp, bool) + or not isinstance(self.timestamp, (int, float)) + or not math.isfinite(self.timestamp) + or self.timestamp < 0 + ): + raise ValueError("timestamp must be a non-negative finite number") + if self.direction not in DIRECTIONS: + raise ValueError("direction must be 'uplink' or 'downlink'") + if ( + isinstance(self.outer_datagram_bytes, bool) + or not isinstance(self.outer_datagram_bytes, int) + or self.outer_datagram_bytes <= 0 + ): + raise ValueError("outer datagram bytes must be a positive integer") + if ( + isinstance(self.encapsulation_overhead, bool) + or not isinstance(self.encapsulation_overhead, int) + or not 0 <= self.encapsulation_overhead <= self.outer_datagram_bytes + ): + raise ValueError( + "encapsulation overhead must be within the outer datagram" + ) + if not isinstance(self.connection_id, str) or not self.connection_id: + raise ValueError("connection ID must be a non-empty string") + if not isinstance(self.capture_point, str) or not self.capture_point: + raise ValueError("capture point must be a non-empty string") + + @property + def inner_datagram_bytes(self): + return self.outer_datagram_bytes - self.encapsulation_overhead + + +@dataclass(frozen=True, slots=True) +class TraceWindow: + """Directional byte and datagram totals for one half-open time window.""" + + started_at: float + ended_at: float + uplink_outer_bytes: int + downlink_outer_bytes: int + uplink_overhead_bytes: int + downlink_overhead_bytes: int + uplink_datagrams: int + downlink_datagrams: int + + @property + def uplink_inner_bytes(self): + return self.uplink_outer_bytes - self.uplink_overhead_bytes + + @property + def downlink_inner_bytes(self): + return self.downlink_outer_bytes - self.downlink_overhead_bytes + + +@dataclass(frozen=True, slots=True) +class IdleDistribution: + count: int + minimum: float | None + median: float | None + p95: float | None + maximum: float | None + mean: float | None + + +@dataclass(frozen=True, slots=True) +class DirectionRatio: + byte_layer: str + uplink_bytes: int + downlink_bytes: int + uplink_to_downlink: float | None + + +@dataclass(frozen=True, slots=True) +class BurstMetrics: + byte_layer: str + burst_count: int + mean_bytes: float + maximum_bytes: int + maximum_datagrams: int + maximum_duration: float + + +def select_trace( + events, + *, + connection_id=None, + capture_point=None, + direction=None, +): + """Return a timestamp-ordered trace restricted to declared dimensions.""" + if direction is not None and direction not in DIRECTIONS: + raise ValueError("direction must be 'uplink' or 'downlink'") + selected = [] + for event in events: + if not isinstance(event, ObserverEvent): + raise ValueError("trace entries must be ObserverEvent instances") + if connection_id is not None and event.connection_id != connection_id: + continue + if capture_point is not None and event.capture_point != capture_point: + continue + if direction is not None and event.direction != direction: + continue + selected.append(event) + return tuple(sorted(selected, key=lambda event: event.timestamp)) + + +def fixed_windows(events, window_seconds, *, origin=None): + """Aggregate outer and encapsulation bytes into fixed half-open windows.""" + try: + window_seconds = float(window_seconds) + except (TypeError, ValueError): + raise ValueError("window seconds must be positive and finite") from None + if not math.isfinite(window_seconds) or window_seconds <= 0: + raise ValueError("window seconds must be positive and finite") + + ordered = select_trace(events) + if not ordered: + return () + if origin is None: + origin = ordered[0].timestamp + try: + origin = float(origin) + except (TypeError, ValueError): + raise ValueError("window origin must be non-negative and finite") from None + if not math.isfinite(origin) or origin < 0: + raise ValueError("window origin must be non-negative and finite") + if ordered[0].timestamp < origin: + raise ValueError("window origin must not follow the first event") + + final_index = int((ordered[-1].timestamp - origin) // window_seconds) + totals = [ + { + "uplink_outer": 0, + "downlink_outer": 0, + "uplink_overhead": 0, + "downlink_overhead": 0, + "uplink_datagrams": 0, + "downlink_datagrams": 0, + } + for _ in range(final_index + 1) + ] + for event in ordered: + index = int((event.timestamp - origin) // window_seconds) + direction = event.direction + totals[index][f"{direction}_outer"] += event.outer_datagram_bytes + totals[index][f"{direction}_overhead"] += event.encapsulation_overhead + totals[index][f"{direction}_datagrams"] += 1 + + return tuple( + TraceWindow( + started_at=origin + index * window_seconds, + ended_at=origin + (index + 1) * window_seconds, + uplink_outer_bytes=window["uplink_outer"], + downlink_outer_bytes=window["downlink_outer"], + uplink_overhead_bytes=window["uplink_overhead"], + downlink_overhead_bytes=window["downlink_overhead"], + uplink_datagrams=window["uplink_datagrams"], + downlink_datagrams=window["downlink_datagrams"], + ) + for index, window in enumerate(totals) + ) + + +def idle_gaps(events): + """Return the complete inter-datagram gap distribution in seconds.""" + ordered = select_trace(events) + return tuple( + current.timestamp - previous.timestamp + for previous, current in zip(ordered, ordered[1:]) + ) + + +def summarize_idle_gaps(events): + gaps = sorted(idle_gaps(events)) + if not gaps: + return IdleDistribution(0, None, None, None, None, None) + count = len(gaps) + middle = count // 2 + median = ( + gaps[middle] + if count % 2 + else (gaps[middle - 1] + gaps[middle]) / 2 + ) + p95_index = max(0, math.ceil(count * 0.95) - 1) + return IdleDistribution( + count=count, + minimum=gaps[0], + median=median, + p95=gaps[p95_index], + maximum=gaps[-1], + mean=sum(gaps) / count, + ) + + +def direction_ratio(events, *, byte_layer="outer"): + """Compute uplink/downlink byte ratio at the requested byte layer.""" + _validate_byte_layer(byte_layer) + uplink = 0 + downlink = 0 + for event in select_trace(events): + byte_count = _event_bytes(event, byte_layer) + if event.direction == UPLINK: + uplink += byte_count + else: + downlink += byte_count + return DirectionRatio( + byte_layer=byte_layer, + uplink_bytes=uplink, + downlink_bytes=downlink, + uplink_to_downlink=(uplink / downlink if downlink else None), + ) + + +def burst_metrics(events, maximum_gap, *, byte_layer="outer"): + """Group adjacent events separated by at most ``maximum_gap`` seconds.""" + _validate_byte_layer(byte_layer) + try: + maximum_gap = float(maximum_gap) + except (TypeError, ValueError): + raise ValueError("maximum burst gap must be non-negative and finite") from None + if not math.isfinite(maximum_gap) or maximum_gap < 0: + raise ValueError("maximum burst gap must be non-negative and finite") + + ordered = select_trace(events) + if not ordered: + return BurstMetrics(byte_layer, 0, 0.0, 0, 0, 0.0) + bursts = [] + started_at = ordered[0].timestamp + previous_at = started_at + byte_count = _event_bytes(ordered[0], byte_layer) + datagrams = 1 + for event in ordered[1:]: + if event.timestamp - previous_at > maximum_gap: + bursts.append((byte_count, datagrams, previous_at - started_at)) + started_at = event.timestamp + byte_count = 0 + datagrams = 0 + byte_count += _event_bytes(event, byte_layer) + datagrams += 1 + previous_at = event.timestamp + bursts.append((byte_count, datagrams, previous_at - started_at)) + + return BurstMetrics( + byte_layer=byte_layer, + burst_count=len(bursts), + mean_bytes=sum(burst[0] for burst in bursts) / len(bursts), + maximum_bytes=max(burst[0] for burst in bursts), + maximum_datagrams=max(burst[1] for burst in bursts), + maximum_duration=max(burst[2] for burst in bursts), + ) + + +def size_autocorrelation(events, *, lag=1, byte_layer="outer"): + """Return Pearson autocorrelation of datagram sizes, or None if undefined.""" + _validate_byte_layer(byte_layer) + if isinstance(lag, bool) or not isinstance(lag, int) or lag <= 0: + raise ValueError("autocorrelation lag must be a positive integer") + sizes = [_event_bytes(event, byte_layer) for event in select_trace(events)] + if len(sizes) <= lag: + return None + left = sizes[:-lag] + right = sizes[lag:] + left_mean = sum(left) / len(left) + right_mean = sum(right) / len(right) + numerator = sum( + (first - left_mean) * (second - right_mean) + for first, second in zip(left, right) + ) + left_variance = sum((value - left_mean) ** 2 for value in left) + right_variance = sum((value - right_mean) ** 2 for value in right) + denominator = math.sqrt(left_variance * right_variance) + return numerator / denominator if denominator else None + + +def _event_bytes(event, byte_layer): + if byte_layer == "outer": + return event.outer_datagram_bytes + return event.inner_datagram_bytes + + +def _validate_byte_layer(byte_layer): + if byte_layer not in BYTE_LAYERS: + raise ValueError("byte layer must be 'outer' or 'inner'") diff --git a/traffic-masking/systemd/README.md b/traffic-masking/systemd/README.md index 9017479..9069920 100644 --- a/traffic-masking/systemd/README.md +++ b/traffic-masking/systemd/README.md @@ -7,11 +7,15 @@ encrypted transport. ## Install Files ```bash -sudo install -d -o root -g root -m 0755 /opt/traffic-masking +sudo useradd --system --home-dir /nonexistent --shell /usr/sbin/nologin \ + traffic-masking +sudo install -d -o root -g root -m 0755 \ + /opt/traffic-masking /etc/traffic-masking sudo install -m 0755 ../traffic_masking_server.py /opt/traffic-masking/ sudo install -m 0755 ../traffic_masking_client.py /opt/traffic-masking/ sudo install -m 0644 ../control_protocol.py /opt/traffic-masking/ sudo install -m 0644 ../masking_lib.py /opt/traffic-masking/ +sudo install -m 0644 ../observer_metrics.py /opt/traffic-masking/ sudo install -m 0644 traffic-masking-server.service /etc/systemd/system/ sudo install -m 0644 traffic-masking-client.service /etc/systemd/system/ @@ -22,16 +26,18 @@ The runtime uses only the Python standard library. ## Install The Shared Key Generate the key on one endpoint, transfer the same binary file securely to the -other endpoint, and restrict it to the service account: +other endpoint, and leave the installed source readable only by root: ```bash umask 077 openssl rand 32 > control.psk -sudo install -o nobody -g nogroup -m 0400 control.psk \ +sudo install -o root -g root -m 0400 control.psk \ /etc/traffic-masking/control.psk ``` -Never place the key value in a unit command or environment variable. +The units load this file through `LoadCredential=` and pass the protected runtime +copy at `%d/control.psk` to the process. Never place the key value in a unit +command or environment variable. ## Configure @@ -58,13 +64,16 @@ sudo systemctl edit traffic-masking-server.service ```ini [Service] ExecStart= -ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py --host 0.0.0.0 --port 8888 --shape-mode rate --min-mbps 2 --max-mbps 8 --max-total-mbps 40 --psk-file /etc/traffic-masking/control.psk +ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py --host 0.0.0.0 --port 8888 --shape-mode rate --min-mbps 2 --max-mbps 8 --max-total-mbps 40 --psk-file %d/control.psk ``` ## Start ```bash sudo systemctl daemon-reload +sudo systemd-analyze verify \ + /etc/systemd/system/traffic-masking-server.service \ + /etc/systemd/system/traffic-masking-client.service sudo systemctl enable --now traffic-masking-server.service sudo systemctl enable --now traffic-masking-client.service ``` @@ -72,6 +81,10 @@ sudo systemctl enable --now traffic-masking-client.service Use the server unit on the emitting endpoint and the client unit on receiving endpoints as appropriate for the deployment. +Both units use `/usr/bin/python3`, run as the dedicated unprivileged +`traffic-masking` account, and allow five seconds for the process to handle +SIGTERM and join its workers. + ## Inspect ```bash diff --git a/traffic-masking/systemd/traffic-masking-client.service b/traffic-masking/systemd/traffic-masking-client.service index 7a57a1f..129265a 100644 --- a/traffic-masking/systemd/traffic-masking-client.service +++ b/traffic-masking/systemd/traffic-masking-client.service @@ -1,19 +1,20 @@ [Unit] -Description=Traffic Masking Client (Maximum Configuration) -Documentation=https://github.com/traffic-masking/README.md +Description=Traffic Masking Client +Documentation=https://github.com/kogeler/tooling/tree/main/traffic-masking After=network-online.target Wants=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=3 [Service] Type=simple -User=nobody -Group=nogroup +User=traffic-masking +Group=traffic-masking -# Working directory WorkingDirectory=/opt/traffic-masking - -# Environment Environment="PYTHONUNBUFFERED=1" +LoadCredential=control.psk:/etc/traffic-masking/control.psk + # Override SERVER_IP with a drop-in file. Environment="SERVER_IP=127.0.0.1" @@ -25,26 +26,34 @@ ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_client.py \ --padding random \ --mtu 1200 \ --stats-interval 10 \ - --psk-file /etc/traffic-masking/control.psk + --psk-file %d/control.psk -# Restart policy -Restart=always +Restart=on-failure RestartSec=10 -StartLimitInterval=60 -StartLimitBurst=3 +TimeoutStopSec=5 -# Security hardening NoNewPrivileges=true PrivateTmp=true +PrivateDevices=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/log - -# Resource limits -LimitNOFILE=65536 -Nice=-10 +ProtectHostname=true +ProtectClock=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET +CapabilityBoundingSet= +AmbientCapabilities= +UMask=0077 +LimitNOFILE=4096 -# Logging StandardOutput=journal StandardError=journal SyslogIdentifier=traffic-masking-client diff --git a/traffic-masking/systemd/traffic-masking-server.service b/traffic-masking/systemd/traffic-masking-server.service index 43be042..8affa93 100644 --- a/traffic-masking/systemd/traffic-masking-server.service +++ b/traffic-masking/systemd/traffic-masking-server.service @@ -1,19 +1,19 @@ [Unit] -Description=Traffic Masking Server (Maximum Configuration) -Documentation=https://github.com/traffic-masking/README.md +Description=Traffic Masking Server +Documentation=https://github.com/kogeler/tooling/tree/main/traffic-masking After=network-online.target Wants=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=3 [Service] Type=simple -User=nobody -Group=nogroup +User=traffic-masking +Group=traffic-masking -# Working directory WorkingDirectory=/opt/traffic-masking - -# Environment Environment="PYTHONUNBUFFERED=1" +LoadCredential=control.psk:/etc/traffic-masking/control.psk # Experimental native profile with an explicit ceiling ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py \ @@ -25,29 +25,37 @@ ExecStart=/usr/bin/python3 /opt/traffic-masking/traffic_masking_server.py \ --padding random \ --mtu 1200 \ --stats-interval 10 \ - --psk-file /etc/traffic-masking/control.psk \ + --psk-file %d/control.psk \ --max-clients 8 \ --max-total-mbps 80 \ --max-handshakes-per-second 20 -# Restart policy -Restart=always +Restart=on-failure RestartSec=10 -StartLimitInterval=60 -StartLimitBurst=3 +TimeoutStopSec=5 -# Security hardening NoNewPrivileges=true PrivateTmp=true +PrivateDevices=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/log - -# Resource limits -LimitNOFILE=65536 -Nice=-10 +ProtectHostname=true +ProtectClock=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectKernelLogs=true +ProtectControlGroups=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +RestrictAddressFamilies=AF_UNIX AF_INET +CapabilityBoundingSet= +AmbientCapabilities= +UMask=0077 +LimitNOFILE=4096 -# Logging StandardOutput=journal StandardError=journal SyslogIdentifier=traffic-masking-server diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index 2e81734..0867692 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -6,10 +6,11 @@ import subprocess import sys import os +import shutil import pytest -from conftest import CLIENT, SERVER, TEST_PSK +from conftest import BASE_DIR, CLIENT, SERVER, TEST_PSK from control_protocol import MIN_CONTROL_MTU from traffic_masking_client import AdaptiveTrafficClient from traffic_masking_server import MaskingTrafficServer @@ -217,3 +218,20 @@ def test_profile_mode_has_native_load_and_optional_cap(): assert uncapped.target_mbps is None assert uncapped.max_mbps is None assert capped.configured_max_mbps == 1 + + +def test_systemd_units_verify_when_analyzer_is_available(): + analyzer = shutil.which("systemd-analyze") + if analyzer is None: + pytest.skip("systemd-analyze is not installed") + units = [ + BASE_DIR / "systemd" / "traffic-masking-server.service", + BASE_DIR / "systemd" / "traffic-masking-client.service", + ] + result = subprocess.run( + [analyzer, "verify", *(str(unit) for unit in units)], + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr diff --git a/traffic-masking/test_concurrency.py b/traffic-masking/test_concurrency.py index 3149358..0e4a15d 100644 --- a/traffic-masking/test_concurrency.py +++ b/traffic-masking/test_concurrency.py @@ -3,12 +3,14 @@ """Per-client shaping isolation and aggregate-cap fairness.""" +import threading from types import SimpleNamespace import pytest from conftest import TEST_PSK from masking_lib import ShapeEvent, mbps_to_bytes_per_second +from traffic_masking_client import AdaptiveTrafficClient from traffic_masking_server import MaskingTrafficServer @@ -60,7 +62,7 @@ def add_client(server, marker): TEST_PSK, ) address = ("127.0.0.1", 20000 + marker) - server.clients[address] = client + server._add_client(address, client) return address, client @@ -167,3 +169,97 @@ def test_profile_gap_starts_after_the_last_fragment_is_submitted(): assert server._next_client_fragment(client) is None clock.advance(0.5) assert server._next_client_fragment(client) is not None + + +def test_threaded_add_touch_cleanup_preserves_registered_client_counters(): + clock = FakeClock() + server = make_server(clock) + main_address, _ = add_client(server, 10) + assert server._record_client_receive(main_address, 100.0) + worker_count = 7 + barrier = threading.Barrier(worker_count) + failures = [] + + def touch_main(): + try: + barrier.wait() + for _ in range(1000): + assert server._record_client_receive(main_address, 100.0, 1) + except Exception as exc: # pragma: no cover - reported after join + failures.append(exc) + + def churn(marker): + try: + barrier.wait() + for port in range(200): + frame = SimpleNamespace( + client_nonce=bytes([marker]) * 16, + session_nonce=bytes([marker + 16]) * 16, + sequence=1, + ) + client = server._new_client_state(frame, 0.0, TEST_PSK, TEST_PSK) + address = ("127.0.0.1", 30000 + marker * 1000 + port) + server._add_client(address, client) + server._record_client_receive(address, 0.0, 1) + except Exception as exc: # pragma: no cover - reported after join + failures.append(exc) + + def cleanup(): + try: + barrier.wait() + for _ in range(400): + server._remove_inactive_clients(10.0, idle_seconds=5.0) + except Exception as exc: # pragma: no cover - reported after join + failures.append(exc) + + threads = [threading.Thread(target=touch_main) for _ in range(4)] + threads.extend(threading.Thread(target=churn, args=(marker,)) for marker in (20, 21)) + threads.append(threading.Thread(target=cleanup)) + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + assert failures == [] + assert all(not thread.is_alive() for thread in threads) + main = next( + item for item in server.snapshot(now=10.0).clients + if item.address == main_address + ) + assert main.bytes_received == 4000 + assert main.packets_received == 4000 + + +def test_server_and_client_stop_are_idempotent_and_join_non_daemon_workers(): + server = MaskingTrafficServer(host="127.0.0.1", port=0, psk=TEST_PSK) + server.start() + assert all(not thread.daemon for thread in server.worker_threads) + assert server.stop() + assert server.stop() + assert all(not thread.is_alive() for thread in server.worker_threads) + + client = AdaptiveTrafficClient("127.0.0.1", 9, psk=TEST_PSK) + client.connect() + assert all(not thread.daemon for thread in client.worker_threads) + assert client.stop() + assert client.stop() + assert all(not thread.is_alive() for thread in client.worker_threads) + + +def test_server_stop_event_interrupts_a_long_pacing_wait(): + server = MaskingTrafficServer(psk=TEST_PSK) + waiter_started = threading.Event() + result = [] + + def wait_for_pacing(): + waiter_started.set() + result.append(server._wait_for_pacing(60.0)) + + thread = threading.Thread(target=wait_for_pacing) + thread.start() + assert waiter_started.wait(timeout=1) + server._stop_event.set() + thread.join(timeout=1) + + assert not thread.is_alive() + assert result == [True] diff --git a/traffic-masking/test_imports.py b/traffic-masking/test_imports.py index d0936d6..ec33f31 100644 --- a/traffic-masking/test_imports.py +++ b/traffic-masking/test_imports.py @@ -12,6 +12,7 @@ CORE_MODULES = [ "control_protocol", "masking_lib", + "observer_metrics", "traffic_masking_server", "traffic_masking_client", ] diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index 2daac9f..a2b14cd 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -8,7 +8,9 @@ CI-safe. Reconnection cases use explicit short keepalive/receive/backoff values. """ +import os import re +import signal import socket import time @@ -66,10 +68,15 @@ def test_transmission_bidirectional(spawn, start_server, psk_file): # Let a few stats windows accumulate, then check real downlink/uplink. time.sleep(4) - rx = last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") - tx = last_match(client, r"Tx:\s*([0-9.]+)\s*Mbps") - assert rx is not None and rx > 0.0, read_log(client) - assert tx is not None and tx > 0.0, read_log(client) + client_log = read_log(client) + rx_windows = [ + float(value) for value in re.findall(r"Rx:\s*([0-9.]+)\s*Mbps", client_log) + ] + tx_windows = [ + float(value) for value in re.findall(r"Tx:\s*([0-9.]+)\s*Mbps", client_log) + ] + assert any(rate > 0.0 for rate in rx_windows), client_log + assert any(rate > 0.0 for rate in tx_windows), client_log def test_reconnection_after_server_restart(spawn, start_server, psk_file): @@ -102,6 +109,7 @@ def server_args(selected_port): client, "Reconnected successfully", 8.0, offset=reconnect_offset ), read_log(client, offset=reconnect_offset) assert server2.process.poll() is None + assert "Receive error" not in read_log(client, offset=reconnect_offset) def test_fixed_rate_is_not_inflated(spawn, start_server, psk_file): @@ -283,3 +291,28 @@ def test_wrong_psk_client_remains_unregistered( assert "New client connected" not in read_log(server) assert "Authenticated session accepted" not in read_log(client) assert last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") == 0.0 + + +def test_sigterm_stops_both_processes_cleanly(spawn, start_server, psk_file): + server, port = start_server( + lambda selected_port: [ + "--host", "127.0.0.1", "--port", str(selected_port), + "--mbps", "1", "--stats-interval", "1", + "--psk-file", str(psk_file), + ], + "signal-server", + ) + client = spawn( + CLIENT, + [ + "--server", "127.0.0.1", "--port", str(port), + "--stats-interval", "1", "--psk-file", str(psk_file), + ], + "signal-client", + ) + assert wait_for(client, "Authenticated session accepted", 5.0), read_log(client) + + for spawned in (client, server): + os.killpg(spawned.process.pid, signal.SIGTERM) + assert spawned.process.wait(timeout=5) == 0, read_log(spawned) + assert "Traceback" not in read_log(spawned) diff --git a/traffic-masking/test_observer_metrics.py b/traffic-masking/test_observer_metrics.py new file mode 100644 index 0000000..71ac074 --- /dev/null +++ b/traffic-masking/test_observer_metrics.py @@ -0,0 +1,116 @@ +# Copyright © 2026 kogeler +# SPDX-License-Identifier: Apache-2.0 + +"""Exact metric checks over a synthetic observer trace.""" + +import pytest + +from observer_metrics import ( + DOWNLINK, + UPLINK, + ObserverEvent, + burst_metrics, + direction_ratio, + fixed_windows, + idle_gaps, + select_trace, + size_autocorrelation, + summarize_idle_gaps, +) + + +@pytest.fixture +def synthetic_trace(): + """Synthetic arithmetic fixture; it is not a legitimate-traffic baseline.""" + return ( + ObserverEvent(0.0, DOWNLINK, 1200, "outer-1", "wan", 100), + ObserverEvent(0.1, DOWNLINK, 800, "outer-1", "wan", 100), + ObserverEvent(0.6, UPLINK, 400, "outer-1", "wan", 80), + ObserverEvent(1.2, DOWNLINK, 1000, "outer-1", "wan", 100), + ObserverEvent(1.3, UPLINK, 500, "outer-1", "wan", 80), + ) + + +def test_fixed_windows_preserve_direction_and_overhead(synthetic_trace): + windows = fixed_windows(synthetic_trace, 1.0, origin=0.0) + + assert len(windows) == 2 + assert windows[0].started_at == 0.0 + assert windows[0].ended_at == 1.0 + assert windows[0].downlink_outer_bytes == 2000 + assert windows[0].uplink_outer_bytes == 400 + assert windows[0].downlink_inner_bytes == 1800 + assert windows[0].uplink_inner_bytes == 320 + assert windows[0].downlink_datagrams == 2 + assert windows[0].uplink_datagrams == 1 + assert windows[1].downlink_outer_bytes == 1000 + assert windows[1].uplink_outer_bytes == 500 + + +def test_idle_direction_and_burst_metrics_are_exact(synthetic_trace): + assert idle_gaps(synthetic_trace) == pytest.approx((0.1, 0.5, 0.6, 0.1)) + idle = summarize_idle_gaps(synthetic_trace) + assert idle.count == 4 + assert idle.minimum == pytest.approx(0.1) + assert idle.median == pytest.approx(0.3) + assert idle.p95 == pytest.approx(0.6) + assert idle.maximum == pytest.approx(0.6) + assert idle.mean == pytest.approx(0.325) + + outer_ratio = direction_ratio(synthetic_trace) + inner_ratio = direction_ratio(synthetic_trace, byte_layer="inner") + assert outer_ratio.uplink_bytes == 900 + assert outer_ratio.downlink_bytes == 3000 + assert outer_ratio.uplink_to_downlink == pytest.approx(0.3) + assert inner_ratio.uplink_bytes == 740 + assert inner_ratio.downlink_bytes == 2700 + + bursts = burst_metrics(synthetic_trace, 0.2) + assert bursts.burst_count == 3 + assert bursts.mean_bytes == pytest.approx(1300) + assert bursts.maximum_bytes == 2000 + assert bursts.maximum_datagrams == 2 + assert bursts.maximum_duration == pytest.approx(0.1) + + +def test_selection_and_autocorrelation_have_declared_dimensions(synthetic_trace): + downlink = select_trace( + reversed(synthetic_trace), + connection_id="outer-1", + capture_point="wan", + direction=DOWNLINK, + ) + assert [event.timestamp for event in downlink] == [0.0, 0.1, 1.2] + + alternating = tuple( + ObserverEvent(index, DOWNLINK, size, "outer-1", "wan") + for index, size in enumerate((100, 200, 100, 200, 100, 200)) + ) + assert size_autocorrelation(alternating) == pytest.approx(-1.0) + assert size_autocorrelation(alternating, lag=2) == pytest.approx(1.0) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"timestamp": -1}, + {"timestamp": "now"}, + {"direction": "sideways"}, + {"outer_datagram_bytes": 0}, + {"encapsulation_overhead": 101}, + {"connection_id": ""}, + {"capture_point": ""}, + ], +) +def test_observer_event_rejects_ambiguous_schema_values(kwargs): + values = { + "timestamp": 1.0, + "direction": UPLINK, + "outer_datagram_bytes": 100, + "connection_id": "outer-1", + "capture_point": "wan", + "encapsulation_overhead": 10, + } + values.update(kwargs) + with pytest.raises(ValueError): + ObserverEvent(**values) diff --git a/traffic-masking/test_uplink.py b/traffic-masking/test_uplink.py index 104e532..63eee84 100644 --- a/traffic-masking/test_uplink.py +++ b/traffic-masking/test_uplink.py @@ -41,7 +41,6 @@ def connected_client(clock, response_ratio=0.25): rng=random.Random(44), byte_source=lambda size: b"u" * size, monotonic_clock=clock, - sleep=clock.advance, ) client.socket = RecordingSocket() client.server_addr = ("192.0.2.10", 8888) @@ -104,3 +103,23 @@ def test_uncredited_data_cannot_bypass_budget_but_keepalive_can_create_debt(): MessageType.KEEPALIVE, allow_budget_debt=True ) > 0 assert client.uplink_budget.available_bytes == 0 + + +def test_client_snapshot_is_atomic_and_uses_monotonic_timestamp(): + clock = FakeClock() + client = connected_client(clock, response_ratio=0.25) + clock.advance(2.0) + client._record_received_data(1000) + sent = client._send_session_message( + MessageType.KEEPALIVE, allow_budget_debt=True + ) + + snapshot = client.snapshot() + assert snapshot.timestamp == 2.0 + assert snapshot.connected + assert snapshot.handshake_accepted + assert snapshot.bytes_received == 1000 + assert snapshot.packets_received == 1 + assert snapshot.bytes_sent == sent + assert snapshot.packets_sent == 1 + assert snapshot.uplink_ratio == pytest.approx(sent / 1000) diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index a22004d..96d1a1b 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -10,10 +10,12 @@ import math import os import random +import signal import socket import struct import threading import time +from dataclasses import dataclass from control_protocol import ( CLIENT_TO_SERVER, @@ -48,6 +50,20 @@ def _env_default(name, fallback): return os.environ.get(name, fallback) +@dataclass(frozen=True, slots=True) +class ClientSnapshot: + timestamp: float + connected: bool + handshake_accepted: bool + server_address: tuple[str, int] | None + bytes_received: int + bytes_sent: int + packets_received: int + packets_sent: int + received_rate_mbps: float + uplink_ratio: float + + class AdaptiveTrafficClient: """Adaptive traffic masking client""" @@ -69,7 +85,6 @@ def __init__( reconnect_delay_min=1.0, reconnect_delay_max=30.0, monotonic_clock=None, - sleep=None, ): # Validate configuration up front; fail fast on invalid inputs. try: @@ -152,20 +167,22 @@ def __init__( self.server_addr = None self.response_ratio = response_ratio # Response traffic ratio self.socket = None - self.running = False self.connected = False self.last_received = 0.0 self._monotonic_clock = monotonic_clock or time.monotonic - self._sleep = sleep or time.sleep + self._state_lock = threading.RLock() + self._stats_lock = threading.Lock() + self._socket_lock = threading.Lock() + self._lifecycle_lock = threading.RLock() + self._stop_event = threading.Event() + self._threads = [] self.stats = { "bytes_received": 0, "bytes_sent": 0, "packets_received": 0, "packets_sent": 0, - "start_time": self._monotonic_clock(), } self.received_rate = 0 - self.rate_window = [] self._rate_window_started = self._monotonic_clock() self._rate_window_bytes = 0 self.sequence = 0 @@ -181,7 +198,7 @@ def __init__( self.mtu = mtu self.packetizer = Packetizer(mtu, FRAME_OVERHEAD) self.data_payload_ceiling = self.packetizer.payload_ceiling - self._send_lock = threading.Lock() + self._send_lock = threading.RLock() self.client_nonce = ZERO_NONCE self.session_nonce = ZERO_NONCE self.pending_send_key = None @@ -202,12 +219,7 @@ def __init__( ) def _create_socket(self): - """Create and configure a new UDP socket""" - if self.socket: - try: - self.socket.close() - except Exception: - pass + """Create a socket, then atomically install it with fresh session state.""" addresses = socket.getaddrinfo( self.server_host, self.server_port, @@ -216,10 +228,48 @@ def _create_socket(self): ) if not addresses: raise OSError(f"could not resolve server {self.server_host}") - self.server_addr = addresses[0][4][:2] - self.socket = init_udp_socket(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) - self.socket.settimeout(2.0) - self._reset_protocol_state() + server_addr = addresses[0][4][:2] + client_socket = init_udp_socket( + socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ) + client_socket.settimeout(0.5) + with self._lifecycle_lock: + if self._stop_event.is_set(): + client_socket.close() + return False + with self._send_lock, self._state_lock, self._socket_lock: + old_socket = self.socket + self.socket = client_socket + self.server_addr = server_addr + self._reset_protocol_state_locked() + if old_socket is not None: + try: + old_socket.close() + except OSError: + pass + return True + + @property + def worker_threads(self): + return tuple(self._threads) + + def _current_socket(self): + with self._socket_lock: + return self.socket + + def _socket_is_current(self, client_socket): + with self._socket_lock: + return self.socket is client_socket + + def _close_socket(self): + with self._socket_lock: + client_socket = self.socket + self.socket = None + if client_socket is not None: + try: + client_socket.close() + except OSError: + pass def _random_nonce(self): nonce = bytes(self._byte_source(NONCE_SIZE)) @@ -228,6 +278,10 @@ def _random_nonce(self): return nonce if nonce != ZERO_NONCE else b"\x01" + nonce[1:] def _reset_protocol_state(self): + with self._send_lock, self._state_lock: + self._reset_protocol_state_locked() + + def _reset_protocol_state_locked(self): self.client_nonce = self._random_nonce() self.session_nonce = ZERO_NONCE self.pending_send_key = None @@ -250,28 +304,37 @@ def _control_padding(self): def _send_registration(self): """Send an authenticated HELLO (UDP: no delivery guarantee).""" - try: - hello = encode_frame( - MessageType.HELLO, - self.client_nonce, - ZERO_NONCE, - self.handshake_sequence, - self.base_key, - padding=self._control_padding(), - ) - self.socket.sendto(hello, self.server_addr) - print( - f"[*] Handshake HELLO sent to {self.server_addr[0]}:" - f"{self.server_addr[1]}", - flush=True, - ) - return True - except Exception as e: - print(f"[!] Registration failed: {e}", flush=True) - return False + with self._send_lock, self._state_lock: + try: + hello = encode_frame( + MessageType.HELLO, + self.client_nonce, + ZERO_NONCE, + self.handshake_sequence, + self.base_key, + padding=self._control_padding(), + ) + client_socket = self._current_socket() + if client_socket is None or self.server_addr is None: + return False + client_socket.sendto(hello, self.server_addr) + print( + f"[*] Handshake HELLO sent to {self.server_addr[0]}:" + f"{self.server_addr[1]}", + flush=True, + ) + return True + except OSError as exc: + if not self._stop_event.is_set(): + print(f"[!] Registration failed: {exc}", flush=True) + return False def _process_datagram(self, datagram, addr): """Authenticate one server datagram and return its DATA payload or None.""" + with self._send_lock, self._state_lock: + return self._process_datagram_locked(datagram, addr) + + def _process_datagram_locked(self, datagram, addr): if self.server_addr is None or addr[:2] != self.server_addr: return None try: @@ -314,7 +377,10 @@ def _process_datagram(self, datagram, addr): padding=self._control_padding(), ) try: - self.socket.sendto(auth, self.server_addr) + client_socket = self._current_socket() + if client_socket is None: + return None + client_socket.sendto(auth, self.server_addr) except OSError: return None return None @@ -362,9 +428,9 @@ def _process_datagram(self, datagram, addr): def _send_session_message( self, message_type, payload=b"", allow_budget_debt=False ): - if not self.handshake_accepted or self.session_send_key is None: - return 0 - with self._send_lock: + with self._send_lock, self._state_lock: + if not self.handshake_accepted or self.session_send_key is None: + return 0 next_sequence = self.control_send_sequence + 1 datagram = encode_frame( message_type, @@ -385,51 +451,59 @@ def _send_session_message( return 0 self.control_send_sequence = next_sequence try: - sent = self.socket.sendto(datagram, self.server_addr) + client_socket = self._current_socket() + if client_socket is None or self.server_addr is None: + return 0 + sent = client_socket.sendto(datagram, self.server_addr) except OSError as exc: - print(f"[!] Send error: {exc}", flush=True) + if not self._stop_event.is_set(): + print(f"[!] Send error: {exc}", flush=True) return 0 if sent != len(datagram): return 0 - self.stats["bytes_sent"] += sent - self.stats["packets_sent"] += 1 + with self._stats_lock: + self.stats["bytes_sent"] += sent + self.stats["packets_sent"] += 1 self.uplink_budget.record_uplink(sent) return sent def _next_keepalive_delay(self): - factor = 1.0 + self._rng.uniform( - -self.keepalive_jitter, self.keepalive_jitter - ) - return self.keepalive_interval * factor + with self._state_lock: + factor = 1.0 + self._rng.uniform( + -self.keepalive_jitter, self.keepalive_jitter + ) + return self.keepalive_interval * factor def _wait_for_server(self, timeout=5.0): """Wait for actual data from the server to confirm connection""" deadline = self._monotonic_clock() + timeout - while self._monotonic_clock() < deadline and self.running: - if self.connected: - return True - self._sleep(min(0.2, timeout)) - return self.connected + while self._monotonic_clock() < deadline and not self._stop_event.is_set(): + with self._state_lock: + if self.connected: + return True + self._stop_event.wait(min(0.2, timeout)) + with self._state_lock: + return self.connected def _reconnect(self): """Reconnect to the server with exponential backoff""" delay = self.reconnect_delay_min - while self.running: + while not self._stop_event.is_set(): print( f"[*] Attempting reconnect in {delay:.1f}s...", flush=True, ) - self._sleep(delay) - if not self.running: + if self._stop_event.wait(delay): break try: - self._create_socket() + if not self._create_socket(): + break self._send_registration() # Wait for actual server response to confirm connection if self._wait_for_server(timeout=self.receive_timeout): print("[*] Reconnected successfully", flush=True) - self.received_rate = 0 - self.rate_window.clear() + with self._state_lock: + self.received_rate = 0 return else: print("[!] No response from server", flush=True) @@ -438,59 +512,63 @@ def _reconnect(self): delay = min(delay * 2, self.reconnect_delay_max) def connect(self): - """Connect to the server""" - self._create_socket() - self.running = True - - print( - f"[*] Traffic masking client connecting to {self.server_addr[0]}:" - f"{self.server_addr[1]}", - flush=True, - ) - auth_mode = "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" - print(f"[*] Control authentication: {auth_mode}", flush=True) - if self.padder.strategy != "none": + """Create the session socket and start managed worker threads.""" + with self._lifecycle_lock: + if any(thread.is_alive() for thread in self._threads): + raise RuntimeError("client is already running") + self._stop_event.clear() + if not self._create_socket(): + raise RuntimeError("client shutdown was requested during connect") + print( - f"[*] Uplink padding: {self.padder.strategy} | mtu={self.mtu}", + f"[*] Traffic masking client connecting to {self.server_addr[0]}:" + f"{self.server_addr[1]}", flush=True, ) + auth_mode = ( + "INSECURE DIAGNOSTIC" if self.insecure_diagnostic else "PSK" + ) + print(f"[*] Control authentication: {auth_mode}", flush=True) + if self.padder.strategy != "none": + print( + f"[*] Uplink padding: {self.padder.strategy} | mtu={self.mtu}", + flush=True, + ) - # Send initial registration packet - self._send_registration() - self.last_received = self._monotonic_clock() + self._send_registration() + with self._state_lock: + self.last_received = self._monotonic_clock() - # Start threads - threading.Thread(target=self.receive_loop, daemon=True).start() - threading.Thread(target=self.send_loop, daemon=True).start() - threading.Thread(target=self.keepalive_loop, daemon=True).start() - threading.Thread(target=self.stats_loop, daemon=True).start() + workers = ( + ("traffic-masking-client-receive", self.receive_loop), + ("traffic-masking-client-send", self.send_loop), + ("traffic-masking-client-keepalive", self.keepalive_loop), + ("traffic-masking-client-stats", self.stats_loop), + ) + self._threads = [ + threading.Thread(name=name, target=target, daemon=False) + for name, target in workers + ] + for thread in self._threads: + thread.start() def generate_response_packet(self, size=None): """Generate uplink response packet""" - if size is None: - # Vary response size - size = self._rng.choice( - [ - self._rng.randint(64, 200), # Small ACK-like - self._rng.randint(200, 600), # Medium - self._rng.randint(600, 1200), # Large - ] - ) - - self.sequence += 1 - - # Format: [type(1)] [sequence(4)] [timestamp(8)] [random_data] - packet_type = b"\x02" # Type: response packet - seq_bytes = struct.pack("!I", self.sequence) - timestamp = struct.pack("!Q", int(time.time() * 1000000)) - - header_size = 1 + 4 + 8 - data_size = max(0, size - header_size) - - # Bulk CSPRNG payload (no per-byte Python RNG in the hot path). + with self._state_lock: + if size is None: + size = self._rng.choice( + [ + self._rng.randint(64, 200), + self._rng.randint(200, 600), + self._rng.randint(600, 1200), + ] + ) + self.sequence += 1 + seq_bytes = struct.pack("!I", self.sequence) + timestamp = struct.pack("!Q", int(time.time() * 1_000_000)) + data_size = max(0, size - 13) random_data = self._byte_source(data_size) - - return packet_type + seq_bytes + timestamp + random_data + return b"\x02" + seq_bytes + timestamp + random_data def send_packet(self, packet): """Send packet to the server""" @@ -508,9 +586,12 @@ def send_packet(self, packet): def receive_loop(self): """Receive packets from the server""" - while self.running: + while not self._stop_event.is_set(): + client_socket = self._current_socket() + if client_socket is None: + break try: - data, addr = self.socket.recvfrom(MAX_DATAGRAM_SIZE) + data, addr = client_socket.recvfrom(MAX_DATAGRAM_SIZE) payload = self._process_datagram(data, addr) if payload is None: @@ -520,54 +601,62 @@ def receive_loop(self): except socket.timeout: continue - except Exception as e: - if self.running: - print(f"[!] Receive error: {e}", flush=True) - self._sleep(0.1) + except OSError as exc: + if self._stop_event.is_set() or not self._socket_is_current( + client_socket + ): + continue + print(f"[!] Receive error: {exc}", flush=True) + self._stop_event.wait(0.1) + except Exception as exc: + if not self._stop_event.is_set(): + print(f"[!] Receive error: {exc}", flush=True) + self._stop_event.wait(0.1) def _record_received_data(self, byte_count): now = self._monotonic_clock() - self.last_received = now - self.connected = True - self.stats["bytes_received"] += byte_count - self.stats["packets_received"] += 1 - self.uplink_budget.record_downlink(byte_count) - self._rate_window_bytes += byte_count - elapsed = now - self._rate_window_started - if elapsed >= 1.0: - self.received_rate = ( - self._rate_window_bytes * 8 / (elapsed * 1_000_000) - ) - self.rate_window.append(self.received_rate) - if len(self.rate_window) > 10: - self.rate_window.pop(0) - self._rate_window_started = now - self._rate_window_bytes = 0 + with self._state_lock: + with self._stats_lock: + self.stats["bytes_received"] += byte_count + self.stats["packets_received"] += 1 + self.last_received = now + self.connected = True + self.uplink_budget.record_downlink(byte_count) + self._rate_window_bytes += byte_count + elapsed = now - self._rate_window_started + if elapsed >= 1.0: + self.received_rate = ( + self._rate_window_bytes * 8 / (elapsed * 1_000_000) + ) + self._rate_window_started = now + self._rate_window_bytes = 0 def keepalive_loop(self): """Send periodic keepalives and handle reconnection""" - while self.running: - self._sleep(self._next_keepalive_delay()) - if not self.running: + while not self._stop_event.is_set(): + if self._stop_event.wait(self._next_keepalive_delay()): break - # Check if we've lost the connection - if ( - self.last_received > 0 - and (self._monotonic_clock() - self.last_received) - > self.receive_timeout - ): + with self._state_lock: + connection_lost = ( + self.last_received > 0 + and (self._monotonic_clock() - self.last_received) + > self.receive_timeout + ) + if connection_lost: print( "[!] Connection lost (no data received), reconnecting...", flush=True, ) - self.connected = False - self.received_rate = 0 + with self._state_lock: + self.connected = False + self.received_rate = 0 self._reconnect() - # After _reconnect returns (success), resume keepalive loop continue - if self.handshake_accepted: + with self._state_lock: + handshake_accepted = self.handshake_accepted + if handshake_accepted: self._send_session_message( MessageType.KEEPALIVE, allow_budget_debt=True ) @@ -576,52 +665,90 @@ def keepalive_loop(self): def send_loop(self): """Spend response credit on framed DATA without bypass traffic.""" - while self.running: - available_datagram_bytes = int(self.uplink_budget.available_bytes) + while not self._stop_event.is_set(): + with self._state_lock: + available_datagram_bytes = int(self.uplink_budget.available_bytes) available_payload_bytes = available_datagram_bytes - FRAME_OVERHEAD if available_payload_bytes >= 13: - packet_size = min( - available_payload_bytes, - self.data_payload_ceiling, - self._rng.randint(200, 1000), - ) + with self._state_lock: + packet_size = min( + available_payload_bytes, + self.data_payload_ceiling, + self._rng.randint(200, 1000), + ) packet = self.generate_response_packet(packet_size) self.send_packet(packet) - self._sleep(0.01) + self._stop_event.wait(0.01) + + def snapshot(self, now=None): + """Return an immutable runtime snapshot for metrics and tests.""" + with self._state_lock: + with self._stats_lock: + timestamp = self._monotonic_clock() if now is None else float(now) + bytes_received = self.stats["bytes_received"] + bytes_sent = self.stats["bytes_sent"] + packets_received = self.stats["packets_received"] + packets_sent = self.stats["packets_sent"] + return ClientSnapshot( + timestamp=timestamp, + connected=self.connected, + handshake_accepted=self.handshake_accepted, + server_address=self.server_addr, + bytes_received=bytes_received, + bytes_sent=bytes_sent, + packets_received=packets_received, + packets_sent=packets_sent, + received_rate_mbps=self.received_rate, + uplink_ratio=self.uplink_budget.observed_ratio, + ) def stats_loop(self): - """Print runtime statistics""" - while self.running: - self._sleep(self.stats_interval) - elapsed = self._monotonic_clock() - self.stats["start_time"] - if elapsed > 0: - recv_mbps = (self.stats["bytes_received"] * 8) / (elapsed * 1_000_000) - send_mbps = (self.stats["bytes_sent"] * 8) / (elapsed * 1_000_000) - recv_pps = self.stats["packets_received"] / elapsed - send_pps = self.stats["packets_sent"] / elapsed - - avg_rate = ( - sum(self.rate_window) / len(self.rate_window) - if self.rate_window - else 0 - ) - conn_status = "connected" if self.connected else "disconnected" - - print( - f"[STATS client total] Rx: {recv_mbps:.2f} Mbps " - f"({recv_pps:.0f} pps) | " - f"Tx: {send_mbps:.2f} Mbps ({send_pps:.0f} pps) | " - f"Avg rate: {avg_rate:.2f} Mbps | " - f"Uplink ratio: {self.uplink_budget.observed_ratio:.3f} | " - f"Status: {conn_status}", - flush=True, - ) - - def stop(self): - """Stop the client""" - self.running = False - if self.socket: - self.socket.close() + """Print instantaneous monotonic-window runtime statistics.""" + previous = self.snapshot() + while not self._stop_event.wait(self.stats_interval): + current = self.snapshot() + elapsed = current.timestamp - previous.timestamp + if elapsed <= 0: + previous = current + continue + recv_mbps = ( + (current.bytes_received - previous.bytes_received) + * 8 + / (elapsed * 1_000_000) + ) + send_mbps = ( + (current.bytes_sent - previous.bytes_sent) + * 8 + / (elapsed * 1_000_000) + ) + recv_pps = ( + current.packets_received - previous.packets_received + ) / elapsed + send_pps = (current.packets_sent - previous.packets_sent) / elapsed + conn_status = "connected" if current.connected else "disconnected" + print( + f"[STATS client window] Rx: {recv_mbps:.2f} Mbps " + f"({recv_pps:.0f} pps) | " + f"Tx: {send_mbps:.2f} Mbps ({send_pps:.0f} pps) | " + f"Uplink ratio: {current.uplink_ratio:.3f} | " + f"Status: {conn_status}", + flush=True, + ) + previous = current + + def stop(self, join_timeout=2.0): + """Request shutdown, close the socket, and join workers once.""" + with self._lifecycle_lock: + self._stop_event.set() + self._close_socket() + threads = tuple(self._threads) + deadline = time.monotonic() + max(0.0, float(join_timeout)) + current = threading.current_thread() + for thread in threads: + if thread is current: + continue + thread.join(max(0.0, deadline - time.monotonic())) + return not any(thread.is_alive() for thread in threads if thread is not current) def main(): @@ -713,11 +840,18 @@ def main(): except ValueError as exc: parser.error(str(exc)) + shutdown_requested = threading.Event() + + def request_shutdown(_signum, _frame): + shutdown_requested.set() + + signal.signal(signal.SIGINT, request_shutdown) + signal.signal(signal.SIGTERM, request_shutdown) + try: client.connect() - while True: - time.sleep(1) - except KeyboardInterrupt: + shutdown_requested.wait() + finally: print("\n[*] Stopping client...", flush=True) client.stop() diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index 9a91554..67dee4c 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -17,6 +17,7 @@ import threading import time from collections import OrderedDict, deque +from dataclasses import dataclass from control_protocol import ( CLIENT_TO_SERVER, @@ -82,6 +83,29 @@ def _env_default(name, fallback): return os.environ.get(name, fallback) +@dataclass(frozen=True, slots=True) +class ServerClientSnapshot: + address: tuple[str, int] + last_seen: float + bytes_received: int + packets_received: int + bytes_sent: int + packets_sent: int + current_rate_mbps: float | None + + +@dataclass(frozen=True, slots=True) +class ServerSnapshot: + timestamp: float + bytes_sent: int + packets_sent: int + clients: tuple[ServerClientSnapshot, ...] + + @property + def client_count(self): + return len(self.clients) + + class PacketGenerator: """Generate opaque rate-mode payloads with variable packet sizes.""" @@ -235,6 +259,7 @@ def __init__( self._clock = clock or time.time self._monotonic_clock = monotonic_clock or time.monotonic self._sleep = sleep or time.sleep + self._sleep_is_injected = sleep is not None self._rng = rng or random.Random() self._byte_source = byte_source or os.urandom self.base_key = psk if psk is not None else INSECURE_DIAGNOSTIC_KEY @@ -266,19 +291,18 @@ def __init__( ) self.socket = None self.clients = {} # Only authenticated/validated sessions. - self.running = False + self._clients_lock = threading.RLock() + self._handshake_lock = threading.RLock() + self._stats_lock = threading.Lock() + self._socket_lock = threading.Lock() + self._lifecycle_lock = threading.RLock() + self._stop_event = threading.Event() + self._threads = [] self.packetizer = Packetizer(mtu, FRAME_OVERHEAD) self.data_payload_ceiling = self.packetizer.payload_ceiling - stats_started = self._monotonic_clock() self.stats = { "bytes_sent": 0, "packets_sent": 0, - "start_time": stats_started, - } - self.last_stats = { - "bytes_sent": 0, - "packets_sent": 0, - "time": stats_started, } self.stats_interval = stats_interval if shape_mode == "profile": @@ -310,16 +334,39 @@ def __init__( ) def start(self): - """Start the server""" - self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - - # Increase buffers for high throughput - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4194304) # 4MB - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4194304) # 4MB - - self.socket.bind((self.host, self.port)) - self.running = True + """Bind the socket and start managed worker threads.""" + with self._lifecycle_lock: + if any(thread.is_alive() for thread in self._threads): + raise RuntimeError("server is already running") + self._stop_event.clear() + server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.setsockopt( + socket.SOL_SOCKET, socket.SO_SNDBUF, 4 * 1024 * 1024 + ) + server_socket.setsockopt( + socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024 + ) + server_socket.settimeout(0.5) + server_socket.bind((self.host, self.port)) + except Exception: + server_socket.close() + raise + with self._socket_lock: + self.socket = server_socket + workers = ( + ("traffic-masking-server-receive", self.receive_loop), + ("traffic-masking-server-send", self.send_loop), + ("traffic-masking-server-stats", self.stats_loop), + ("traffic-masking-server-cleanup", self.cleanup_loop), + ) + self._threads = [ + threading.Thread(name=name, target=target, daemon=False) + for name, target in workers + ] + for thread in self._threads: + thread.start() print( f"[*] Traffic masking server started on {self.host}:{self.port}", flush=True @@ -353,58 +400,121 @@ def start(self): flush=True, ) - # Start threads - threading.Thread(target=self.receive_loop, daemon=True).start() - threading.Thread(target=self.send_loop, daemon=True).start() - threading.Thread(target=self.stats_loop, daemon=True).start() - threading.Thread(target=self.cleanup_loop, daemon=True).start() + @property + def worker_threads(self): + return tuple(self._threads) + + def _current_socket(self): + with self._socket_lock: + return self.socket + + def _close_socket(self): + with self._socket_lock: + server_socket = self.socket + self.socket = None + if server_socket is not None: + try: + server_socket.close() + except OSError: + pass + + def _wait_for_pacing(self, delay): + if self._sleep_is_injected: + self._sleep(delay) + return self._stop_event.is_set() + return self._stop_event.wait(delay) def _prune_handshake_state(self, now): - while self._handshake_times and self._handshake_times[0] <= now - 1.0: - self._handshake_times.popleft() - for mapping in (self._prevalidation, self._accepted_auth): - expired = [key for key, value in mapping.items() if value["expires"] < now] - for key in expired: - del mapping[key] + with self._handshake_lock: + while self._handshake_times and self._handshake_times[0] <= now - 1.0: + self._handshake_times.popleft() + for mapping in (self._prevalidation, self._accepted_auth): + expired = [ + key for key, value in mapping.items() if value["expires"] < now + ] + for key in expired: + del mapping[key] def _consume_handshake_slot(self, now): - self._prune_handshake_state(now) - if len(self._handshake_times) >= self.max_handshakes_per_second: - return False - self._handshake_times.append(now) - return True + with self._handshake_lock: + self._prune_handshake_state(now) + if len(self._handshake_times) >= self.max_handshakes_per_second: + return False + self._handshake_times.append(now) + return True def _record_prevalidation_input(self, addr, byte_count, now): - self._prune_handshake_state(now) - entry = self._prevalidation.get(addr) - if entry is None: - if len(self._prevalidation) >= self._handshake_state_limit: - return None - entry = {"received": 0, "replied": 0, "expires": now + self.cookie_ttl} - self._prevalidation[addr] = entry - entry["received"] += byte_count - entry["expires"] = now + self.cookie_ttl - self._prevalidation.move_to_end(addr) - return entry + with self._handshake_lock: + self._prune_handshake_state(now) + entry = self._prevalidation.get(addr) + if entry is None: + if len(self._prevalidation) >= self._handshake_state_limit: + return None + entry = { + "received": 0, + "replied": 0, + "expires": now + self.cookie_ttl, + } + self._prevalidation[addr] = entry + entry["received"] += byte_count + entry["expires"] = now + self.cookie_ttl + self._prevalidation.move_to_end(addr) + return entry def _send_prevalidation(self, addr, datagram, entry): - if entry["replied"] + len(datagram) > entry["received"] * 3: - return False - try: - sent = self.socket.sendto(datagram, addr) - except OSError: - return False - if sent != len(datagram): - return False - entry["replied"] += sent - return True + with self._handshake_lock: + if entry["replied"] + len(datagram) > entry["received"] * 3: + return False + server_socket = self._current_socket() + if server_socket is None: + return False + try: + sent = server_socket.sendto(datagram, addr) + except OSError: + return False + if sent != len(datagram): + return False + entry["replied"] += sent + return True def prevalidation_totals(self, addr): """Return received/replied pre-validation bytes for tests/diagnostics.""" - entry = self._prevalidation.get(addr) - if entry is None: - return 0, 0 - return entry["received"], entry["replied"] + with self._handshake_lock: + entry = self._prevalidation.get(addr) + if entry is None: + return 0, 0 + return entry["received"], entry["replied"] + + def _add_client(self, addr, client): + with self._clients_lock: + self.clients[addr] = client + + def _get_client(self, addr): + with self._clients_lock: + return self.clients.get(addr) + + def _client_items(self): + with self._clients_lock: + return tuple(self.clients.items()) + + def _remove_client(self, addr, expected=None): + with self._clients_lock: + client = self.clients.get(addr) + if client is None or (expected is not None and client is not expected): + return None + return self.clients.pop(addr) + + def _record_client_receive(self, addr, now, byte_count=0): + with self._clients_lock: + client = self.clients.get(addr) + if client is None: + return False + with client["lock"]: + client["last_seen"] = now + if byte_count: + client["bytes_received"] += byte_count + client["packets_received"] += 1 + return True def _control_padding(self): return make_padding( @@ -465,15 +575,6 @@ def _handle_auth(self, frame, addr, entry, now): return False replay_key = frame.client_nonce + frame.session_nonce - self._prune_handshake_state(now) - if replay_key in self._accepted_auth: - return False - prospective_clients = len(self.clients) - (1 if addr in self.clients else 0) + 1 - if prospective_clients > self.max_clients: - return False - if len(self._accepted_auth) >= self._handshake_state_limit: - return False - receive_key = derive_session_key( self.base_key, frame.client_nonce, @@ -494,18 +595,29 @@ def _handle_auth(self, frame, addr, entry, now): send_key, padding=self._control_padding(), ) - if not self._send_prevalidation(addr, accept, entry): - return False - - self._accepted_auth[replay_key] = { - "expires": now + self.cookie_ttl - } - self.clients[addr] = self._new_client_state( + client = self._new_client_state( frame, now, receive_key, send_key, ) + with self._handshake_lock, self._clients_lock: + self._prune_handshake_state(now) + if replay_key in self._accepted_auth: + return False + prospective_clients = ( + len(self.clients) - (1 if addr in self.clients else 0) + 1 + ) + if prospective_clients > self.max_clients: + return False + if len(self._accepted_auth) >= self._handshake_state_limit: + return False + if not self._send_prevalidation(addr, accept, entry): + return False + self._accepted_auth[replay_key] = { + "expires": now + self.cookie_ttl + } + self._add_client(addr, client) print(f"[+] New client connected: {addr}", flush=True) return True @@ -539,13 +651,12 @@ def _new_client_state(self, frame, now, receive_key, send_key): generator = profile_event_generator(self.profile, rng=client_rng) return { + "lock": threading.RLock(), "last_seen": now, "bytes_received": 0, "packets_received": 0, "bytes_sent": 0, "packets_sent": 0, - "last_bytes_sent": 0, - "last_packets_sent": 0, "client_nonce": frame.client_nonce, "session_nonce": frame.session_nonce, "receive_key": receive_key, @@ -578,29 +689,31 @@ def _new_client_state(self, frame, now, receive_key, send_key): } def _handle_session_frame(self, inspected, datagram, addr, now): - client = self.clients.get(addr) - if client is None: - return False - if inspected.message_type not in (MessageType.KEEPALIVE, MessageType.DATA): - return False - if ( - inspected.client_nonce != client["client_nonce"] - or inspected.session_nonce != client["session_nonce"] - ): - return False - try: - frame = decode_frame(datagram, client["receive_key"]) - except ProtocolError: - return False - if frame.sequence <= client["receive_sequence"]: - return False - - client["receive_sequence"] = frame.sequence - client["last_seen"] = now - if frame.message_type is MessageType.DATA: - client["bytes_received"] += len(datagram) - client["packets_received"] += 1 - return True + with self._clients_lock: + client = self.clients.get(addr) + if client is None: + return False + with client["lock"]: + if inspected.message_type not in ( + MessageType.KEEPALIVE, + MessageType.DATA, + ): + return False + if ( + inspected.client_nonce != client["client_nonce"] + or inspected.session_nonce != client["session_nonce"] + ): + return False + try: + frame = decode_frame(datagram, client["receive_key"]) + except ProtocolError: + return False + if frame.sequence <= client["receive_sequence"]: + return False + + client["receive_sequence"] = frame.sequence + byte_count = len(datagram) if frame.message_type is MessageType.DATA else 0 + return self._record_client_receive(addr, now, byte_count) def handle_datagram(self, datagram, addr): """Validate and dispatch one UDP datagram; return whether it was accepted.""" @@ -614,44 +727,55 @@ def handle_datagram(self, datagram, addr): if inspected.message_type not in (MessageType.HELLO, MessageType.AUTH): return False - entry = self._record_prevalidation_input(addr, len(datagram), now) - if entry is None or not self._consume_handshake_slot(now): - return False - try: - frame = decode_frame(datagram, self.base_key) - except ProtocolError: - return False - if frame.message_type is MessageType.HELLO: - return self._handle_hello(frame, addr, entry, now) - return self._handle_auth(frame, addr, entry, now) + with self._handshake_lock: + entry = self._record_prevalidation_input(addr, len(datagram), now) + if entry is None or not self._consume_handshake_slot(now): + return False + try: + frame = decode_frame(datagram, self.base_key) + except ProtocolError: + return False + if frame.message_type is MessageType.HELLO: + return self._handle_hello(frame, addr, entry, now) + return self._handle_auth(frame, addr, entry, now) def receive_loop(self): """Receive and authenticate packets from clients.""" - while self.running: + while not self._stop_event.is_set(): + server_socket = self._current_socket() + if server_socket is None: + break try: - data, addr = self.socket.recvfrom(MAX_DATAGRAM_SIZE) + data, addr = server_socket.recvfrom(MAX_DATAGRAM_SIZE) self.handle_datagram(data, addr) + except socket.timeout: + continue + except OSError as exc: + if self._stop_event.is_set() or server_socket is not self._current_socket(): + continue + print(f"[!] Receive error: {exc}", flush=True) except Exception as exc: - if self.running: + if not self._stop_event.is_set(): print(f"[!] Receive error: {exc}", flush=True) def _frame_data_for_client(self, client, payload): - client["send_sequence"] += 1 - return encode_frame( - MessageType.DATA, - client["client_nonce"], - client["session_nonce"], - client["send_sequence"], - client["send_key"], - payload=payload, - ) + with client["lock"]: + client["send_sequence"] += 1 + return encode_frame( + MessageType.DATA, + client["client_nonce"], + client["session_nonce"], + client["send_sequence"], + client["send_key"], + payload=payload, + ) def send_loop(self): """Serve one datagram per client per round under the aggregate cap.""" - while self.running: - clients = list(self.clients.items()) + while not self._stop_event.is_set(): + clients = self._client_items() if not clients: - self._sleep(0.1) + self._stop_event.wait(0.1) continue sent_any = False @@ -674,38 +798,40 @@ def send_loop(self): delay, max(0.0, next_ready_at - self._monotonic_clock()), ) - self._sleep(delay) + self._stop_event.wait(delay) def _next_client_fragment(self, client): - if client["pending_fragments"]: + with client["lock"]: + if client["pending_fragments"]: + fragment = client["pending_fragments"].popleft() + if not client["pending_fragments"]: + client["delay_after_send"] = client["pending_event_delay"] + return fragment + now = self._monotonic_clock() + if now < client["next_event_at"]: + return None + + event = self._next_shape_event(client) + if not event.byte_count: + client["next_event_at"] = now + event.delay + return None + payload = self._make_event_payload(client, event) + client["pending_fragments"].extend(self.packetizer.packetize(payload)) + if not client["pending_fragments"]: + return None + client["pending_event_delay"] = event.delay fragment = client["pending_fragments"].popleft() if not client["pending_fragments"]: - client["delay_after_send"] = client["pending_event_delay"] + client["delay_after_send"] = event.delay return fragment - now = self._monotonic_clock() - if now < client["next_event_at"]: - return None - - event = self._next_shape_event(client) - if not event.byte_count: - client["next_event_at"] = now + event.delay - return None - payload = self._make_event_payload(client, event) - client["pending_fragments"].extend(self.packetizer.packetize(payload)) - if not client["pending_fragments"]: - return None - client["pending_event_delay"] = event.delay - fragment = client["pending_fragments"].popleft() - if not client["pending_fragments"]: - client["delay_after_send"] = event.delay - return fragment def _complete_client_fragment(self, client): - if client["delay_after_send"] is not None: - client["next_event_at"] = ( - self._monotonic_clock() + client["delay_after_send"] - ) - client["delay_after_send"] = None + with client["lock"]: + if client["delay_after_send"] is not None: + client["next_event_at"] = ( + self._monotonic_clock() + client["delay_after_send"] + ) + client["delay_after_send"] = None def _next_shape_event(self, client): if self.shape_mode == "profile": @@ -737,102 +863,138 @@ def _send_fragment(self, addr, client, fragment): client_reservation.delay if client_reservation else 0.0, total_reservation.delay, ) - if delay: - self._sleep(delay) sent = 0 try: - sent = self.socket.sendto(framed, addr) + if delay and self._wait_for_pacing(delay): + return 0 + server_socket = self._current_socket() + if server_socket is None: + return 0 + sent = server_socket.sendto(framed, addr) if sent == len(framed): - self.stats["bytes_sent"] += sent - self.stats["packets_sent"] += 1 - client["bytes_sent"] += sent - client["packets_sent"] += 1 + with self._stats_lock: + self.stats["bytes_sent"] += sent + self.stats["packets_sent"] += 1 + with client["lock"]: + client["bytes_sent"] += sent + client["packets_sent"] += 1 else: sent = max(0, min(sent, len(framed))) except OSError as exc: - print(f"[!] Send error to client {addr}: {exc}", flush=True) + if not self._stop_event.is_set(): + print(f"[!] Send error to client {addr}: {exc}", flush=True) finally: if client_reservation: limiter.commit(client_reservation, successful_bytes=sent) self.total_rate_limiter.commit( total_reservation, successful_bytes=sent ) + return sent + + def _remove_inactive_clients(self, now, idle_seconds=30.0): + removed = [] + with self._clients_lock: + for addr, client in tuple(self.clients.items()): + with client["lock"]: + inactive = now - client["last_seen"] > idle_seconds + if inactive and self._remove_client(addr, expected=client) is not None: + removed.append(addr) + return tuple(removed) def cleanup_loop(self): - """Remove inactive clients""" - while self.running: - current_time = time.time() - inactive_clients = [] - - for addr, info in self.clients.items(): - if current_time - info["last_seen"] > 30: # 30 seconds of inactivity - inactive_clients.append(addr) - - for addr in inactive_clients: + """Remove inactive clients until shutdown is requested.""" + while not self._stop_event.wait(5.0): + for addr in self._remove_inactive_clients(self._clock()): print(f"[-] Client removed (inactive): {addr}", flush=True) - del self.clients[addr] - time.sleep(5) + def snapshot(self, now=None): + """Return an immutable, internally consistent runtime snapshot.""" + clients = [] + with self._stats_lock, self._clients_lock: + timestamp = self._monotonic_clock() if now is None else float(now) + bytes_sent = self.stats["bytes_sent"] + packets_sent = self.stats["packets_sent"] + for addr, client in self.clients.items(): + with client["lock"]: + clients.append( + ServerClientSnapshot( + address=addr, + last_seen=client["last_seen"], + bytes_received=client["bytes_received"], + packets_received=client["packets_received"], + bytes_sent=client["bytes_sent"], + packets_sent=client["packets_sent"], + current_rate_mbps=client["current_rate_mbps"], + ) + ) + return ServerSnapshot( + timestamp=timestamp, + bytes_sent=bytes_sent, + packets_sent=packets_sent, + clients=tuple(sorted(clients, key=lambda item: item.address)), + ) def stats_loop(self): """Print total and per-client application-datagram egress rates.""" - while self.running: - self._sleep(self.stats_interval) - now = self._monotonic_clock() - - # Calculate instantaneous rates based on delta since last stats - time_delta = now - self.last_stats["time"] - bytes_delta = self.stats["bytes_sent"] - self.last_stats["bytes_sent"] - packets_delta = self.stats["packets_sent"] - self.last_stats["packets_sent"] - - if time_delta > 0: - # Instantaneous rate (not cumulative average), decimal Mbps - mbps = (bytes_delta * 8) / (time_delta * 1_000_000) - pps = packets_delta / time_delta - - pattern_desc = ( - "rate:per-client" - if self.shape_mode == "rate" and self.min_mbps is None - else f"floating:{self.min_mbps:.2f}-{self.max_mbps:.2f}Mbps" - if self.shape_mode == "rate" - else f"experimental-profile:{self.profile.value}" + previous = self.snapshot() + while not self._stop_event.wait(self.stats_interval): + current = self.snapshot() + time_delta = current.timestamp - previous.timestamp + if time_delta <= 0: + previous = current + continue + bytes_delta = current.bytes_sent - previous.bytes_sent + packets_delta = current.packets_sent - previous.packets_sent + mbps = bytes_delta * 8 / (time_delta * 1_000_000) + pps = packets_delta / time_delta + pattern_desc = ( + "rate:per-client" + if self.shape_mode == "rate" and self.min_mbps is None + else f"floating:{self.min_mbps:.2f}-{self.max_mbps:.2f}Mbps" + if self.shape_mode == "rate" + else f"experimental-profile:{self.profile.value}" + ) + previous_clients = {item.address: item for item in previous.clients} + client_rates = [] + for client in current.clients: + prior = previous_clients.get(client.address) + prior_bytes = prior.bytes_sent if prior is not None else 0 + client_mbps = ( + (client.bytes_sent - prior_bytes) * 8 + / (time_delta * 1_000_000) ) - client_rates = [] - for addr, client in self.clients.items(): - client_bytes = ( - client["bytes_sent"] - client["last_bytes_sent"] - ) - client_mbps = client_bytes * 8 / (time_delta * 1_000_000) - target = client["current_rate_mbps"] - target_text = ( - f",target={target:.2f}Mbps" - if target is not None - else ",native-profile" - ) - client_rates.append( - f"{addr[0]}:{addr[1]}={client_mbps:.2f}Mbps{target_text}" - ) - client["last_bytes_sent"] = client["bytes_sent"] - client["last_packets_sent"] = client["packets_sent"] - per_client = ";".join(client_rates) or "none" - print( - f"[STATS] Clients: {len(self.clients)} | " - f"Total Rate: {mbps:.2f} Mbps | " - f"Total PPS: {pps:.0f} | " - f"Per-client: {per_client} | Pattern: {pattern_desc}", - flush=True, + target_text = ( + f",target={client.current_rate_mbps:.2f}Mbps" + if client.current_rate_mbps is not None + else ",native-profile" ) - - # Update last stats for next iteration - self.last_stats["bytes_sent"] = self.stats["bytes_sent"] - self.last_stats["packets_sent"] = self.stats["packets_sent"] - self.last_stats["time"] = now - - def stop(self): - """Stop the server""" - self.running = False - if self.socket: - self.socket.close() + client_rates.append( + f"{client.address[0]}:{client.address[1]}=" + f"{client_mbps:.2f}Mbps{target_text}" + ) + per_client = ";".join(client_rates) or "none" + print( + f"[STATS] Clients: {current.client_count} | " + f"Total Rate: {mbps:.2f} Mbps | " + f"Total PPS: {pps:.0f} | " + f"Per-client: {per_client} | Pattern: {pattern_desc}", + flush=True, + ) + previous = current + + def stop(self, join_timeout=2.0): + """Request shutdown, close the socket, and join workers once.""" + with self._lifecycle_lock: + self._stop_event.set() + self._close_socket() + threads = tuple(self._threads) + deadline = time.monotonic() + max(0.0, float(join_timeout)) + current = threading.current_thread() + for thread in threads: + if thread is current: + continue + thread.join(max(0.0, deadline - time.monotonic())) + return not any(thread.is_alive() for thread in threads if thread is not current) def main(): From 900f715bcd0d17ccea4d21c97f24077050457fd7 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:58:38 +0300 Subject: [PATCH 09/10] traffic-masking: harden live end-to-end coverage --- traffic-masking/Makefile | 2 +- traffic-masking/README.md | 7 +- traffic-masking/SUMMARY.md | 9 +- traffic-masking/conftest.py | 85 ++- traffic-masking/test_live.py | 687 +++++++++++++++------- traffic-masking/traffic_masking_client.py | 58 +- traffic-masking/traffic_masking_server.py | 59 +- 7 files changed, 646 insertions(+), 261 deletions(-) diff --git a/traffic-masking/Makefile b/traffic-masking/Makefile index 1cf8423..a6eedd5 100644 --- a/traffic-masking/Makefile +++ b/traffic-masking/Makefile @@ -9,7 +9,7 @@ DEPS_STAMP := $(VENV)/.deps-installed COV := --cov=control_protocol --cov=masking_lib --cov=observer_metrics \ --cov=traffic_masking_server --cov=traffic_masking_client \ - --cov-branch --cov-report=term-missing + --cov-branch --cov-report=term-missing --cov-fail-under=75 .PHONY: venv test test-fast test-live lint run-server run-client clean diff --git a/traffic-masking/README.md b/traffic-masking/README.md index 03868fa..cdf90c2 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -118,7 +118,8 @@ Client health timings are configurable with: `--stats-interval` or `TRAFFIC_MASKING_STATS_INTERVAL` controls reporting on either endpoint. CLI values override environment defaults. Both endpoints report instantaneous monotonic windows. Server logs label total and per-client rates -separately. +separately. `--stats-json` switches periodic output to `[SNAPSHOT]`-prefixed JSON +with cumulative counters, state, and the current monotonic window. `MaskingTrafficServer.snapshot()` and `AdaptiveTrafficClient.snapshot()` return immutable counter/state snapshots for tests and operational integrations. The @@ -147,7 +148,9 @@ make test ``` The live suite starts real loopback server/client processes and requires local -UDP sockets and process creation. +UDP sockets and process creation. On Linux with procfs it also verifies that each +process owns only its declared UDP socket. This application-level smoke does not +replace a capture at the enclosing encrypted transport boundary. ## Docker diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index 65a158c..9c31643 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -74,10 +74,11 @@ handcrafted experimental inputs, not measured baselines. Mbps values are decimal application rates. IP, UDP, and enclosing encrypted transport overhead require a separate observer measurement. -Both processes expose immutable structured snapshots. Human-readable logs derive -instantaneous rates from consecutive snapshots rather than cumulative averages. -Their sockets and session counters are synchronized, and SIGINT/SIGTERM trigger a -bounded join of non-daemon workers. +Both processes expose immutable structured snapshots. `--stats-json` emits those +counters, state, and monotonic windows as machine-readable process output. +Human-readable logs derive instantaneous rates from consecutive snapshots rather +than cumulative averages. Their sockets and session counters are synchronized, +and SIGINT/SIGTERM trigger a bounded join of non-daemon workers. ## Security Boundary diff --git a/traffic-masking/conftest.py b/traffic-masking/conftest.py index e9b47cf..b979f94 100644 --- a/traffic-masking/conftest.py +++ b/traffic-masking/conftest.py @@ -8,6 +8,7 @@ separate pytest.ini. """ +import json import os import signal import socket @@ -23,6 +24,7 @@ SERVER = str(BASE_DIR / "traffic_masking_server.py") CLIENT = str(BASE_DIR / "traffic_masking_client.py") TEST_PSK = b"traffic-masking-test-key-material-32" +SNAPSHOT_PREFIX = "[SNAPSHOT] " @dataclass @@ -80,6 +82,22 @@ def read_log(log, offset=0): return "" +def read_snapshots(log, offset=0): + """Parse complete machine-readable snapshots from a process log.""" + snapshots = [] + for line in read_log(log, offset=offset).splitlines(): + marker = line.find(SNAPSHOT_PREFIX) + if marker < 0: + continue + try: + snapshot = json.loads(line[marker + len(SNAPSHOT_PREFIX) :]) + except json.JSONDecodeError: + continue + if isinstance(snapshot, dict): + snapshots.append(snapshot) + return snapshots + + def _log_tail(log, offset=0, limit=4000): contents = read_log(log, offset=offset) return contents[-limit:] if contents else "" @@ -93,7 +111,7 @@ def wait_for(log, needle, timeout, offset=0, report_failure=True): return True if isinstance(log, SpawnedProcess) and log.process.poll() is not None: break - time.sleep(0.1) + time.sleep(0.05) if report_failure: print( f"Timed out waiting for {needle!r} in {_path_from_log(log)}:\n" @@ -103,33 +121,62 @@ def wait_for(log, needle, timeout, offset=0, report_failure=True): return False -def last_match(log, pattern, offset=0): - """Return the last regex group-1 match in a log as float, or None.""" - import re +def wait_for_snapshot(log, predicate, timeout, offset=0, description="snapshot"): + """Return the first structured snapshot satisfying ``predicate``.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for snapshot in read_snapshots(log, offset=offset): + if predicate(snapshot): + return snapshot + if isinstance(log, SpawnedProcess) and log.process.poll() is not None: + break + time.sleep(0.05) + print( + f"Timed out waiting for {description} in {_path_from_log(log)}:\n" + f"{_log_tail(log, offset=offset)}", + file=sys.stderr, + ) + return None + - values = re.findall(pattern, read_log(log, offset=offset)) - return float(values[-1]) if values else None +def process_group_exists(process_group_id): + """Return whether a child process group still has any members.""" + try: + os.killpg(process_group_id, 0) + except ProcessLookupError: + return False + return True def stop_process(spawned, timeout=3): """Terminate a spawned process group, escalating to SIGKILL after timeout.""" process = spawned.process if isinstance(spawned, SpawnedProcess) else spawned - if process.poll() is not None: - return - - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - return - - try: - process.wait(timeout=timeout) - except subprocess.TimeoutExpired: + if process.poll() is None: try: - os.killpg(process.pid, signal.SIGKILL) + os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: pass - process.wait(timeout=timeout) + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=timeout) + + if not process_group_exists(process.pid): + return process.returncode + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + return process.returncode + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_group_exists(process.pid): + return process.returncode + time.sleep(0.05) + raise RuntimeError(f"process group {process.pid} survived process teardown") @pytest.fixture diff --git a/traffic-masking/test_live.py b/traffic-masking/test_live.py index a2b14cd..b4761bd 100644 --- a/traffic-masking/test_live.py +++ b/traffic-masking/test_live.py @@ -1,22 +1,26 @@ # Copyright © 2026 kogeler # SPDX-License-Identifier: Apache-2.0 -"""Native end-to-end tests: spawn the real server and client on loopback. - -Ported from the former standalone test_traffic_masking.py and -test_realistic_patterns.py runners so nothing runs outside pytest. Bounded to stay -CI-safe. Reconnection cases use explicit short keepalive/receive/backoff values. -""" +"""Bounded end-to-end tests for real server and client processes on loopback.""" import os -import re import signal import socket import time +from pathlib import Path import pytest -from conftest import CLIENT, TEST_PSK, last_match, read_log, stop_process, wait_for +from conftest import ( + CLIENT, + TEST_PSK, + process_group_exists, + read_log, + read_snapshots, + stop_process, + wait_for, + wait_for_snapshot, +) from control_protocol import ( NONCE_SIZE, ZERO_NONCE, @@ -27,292 +31,535 @@ pytestmark = pytest.mark.live +STATS_INTERVAL = "0.2" + -def _server_args(port, psk_file, lo=2, hi=4): +def _server_base(port, psk_file): return [ "--host", "127.0.0.1", "--port", str(port), - "--shape-mode", "profile", "--max-mbps", str(hi), - "--profile", "mixed", "--stats-interval", "1", + "--stats-interval", STATS_INTERVAL, "--stats-json", "--psk-file", str(psk_file), ] +def _fixed_server_args(port, psk_file, target=1.0, total_cap=100.0): + return [ + *_server_base(port, psk_file), + "--mbps", str(target), "--max-total-mbps", str(total_cap), + ] + + +def _profile_server_args(port, psk_file, profile="mixed"): + return [ + *_server_base(port, psk_file), + "--shape-mode", "profile", "--profile", profile, + "--max-mbps", "8", + ] + + +def _client_args(port, psk_file, *extra): + return [ + "--server", "127.0.0.1", "--port", str(port), + "--stats-interval", STATS_INTERVAL, "--stats-json", + "--psk-file", str(psk_file), + *extra, + ] + + def _fast_client_timings(): return [ - "--keepalive-interval", "0.2", + "--keepalive-interval", "0.1", "--keepalive-jitter", "0", - "--receive-timeout", "0.8", - "--reconnect-delay-min", "0.2", - "--reconnect-delay-max", "0.5", + "--receive-timeout", "0.4", + "--reconnect-delay-min", "0.1", + "--reconnect-delay-max", "0.2", ] -def test_transmission_bidirectional(spawn, start_server, psk_file): - """Client connects, receives downlink and emits uplink; server sees the client.""" - server, port = start_server( - lambda selected_port: _server_args(selected_port, psk_file), "server" +def _is_client_data(snapshot, minimum_bytes=1): + return ( + snapshot.get("kind") == "client" + and snapshot.get("connected") is True + and snapshot.get("handshake_accepted") is True + and snapshot.get("totals", {}).get("bytes_received", 0) >= minimum_bytes ) - client = spawn( - CLIENT, - [ - "--server", "127.0.0.1", "--port", str(port), - "--response", "0.3", "--padding", "random", - "--stats-interval", "1", "--psk-file", str(psk_file), - ], - "client", - ) - assert wait_for(client, "Rx:", 10.0), read_log(client) - assert wait_for(server, "New client connected", 5.0), read_log(server) +def _rate_between(first, second, key="bytes_received"): + elapsed = second["timestamp"] - first["timestamp"] + byte_count = second["totals"][key] - first["totals"][key] + return byte_count * 8 / (elapsed * 1_000_000) - # Let a few stats windows accumulate, then check real downlink/uplink. - time.sleep(4) - client_log = read_log(client) - rx_windows = [ - float(value) for value in re.findall(r"Rx:\s*([0-9.]+)\s*Mbps", client_log) - ] - tx_windows = [ - float(value) for value in re.findall(r"Tx:\s*([0-9.]+)\s*Mbps", client_log) - ] - assert any(rate > 0.0 for rate in rx_windows), client_log - assert any(rate > 0.0 for rate in tx_windows), client_log +def test_correct_psk_downlink_matches_fixed_decimal_rate( + spawn, start_server, psk_file +): + target = 1.0 + server, port = start_server( + lambda selected_port: _fixed_server_args( + selected_port, psk_file, target=target + ), + "fixed-server", + ) + client = spawn(CLIENT, _client_args(port, psk_file), "fixed-client") -def test_reconnection_after_server_restart(spawn, start_server, psk_file): - """Three-phase: connected -> server down (no false success) -> restarted -> resumed.""" - def server_args(selected_port): - return _server_args(selected_port, psk_file) + first = wait_for_snapshot( + client, + lambda snapshot: _is_client_data(snapshot, minimum_bytes=25_000), + 5.0, + description="authenticated client data", + ) + assert first is not None + second = wait_for_snapshot( + client, + lambda snapshot: ( + _is_client_data(snapshot) + and snapshot["timestamp"] >= first["timestamp"] + 1.0 + ), + 3.0, + description="one-second fixed-rate observation", + ) + assert second is not None + observed = _rate_between(first, second) + assert target * 0.85 <= observed <= target * 1.15, observed + + server_snapshot = wait_for_snapshot( + server, + lambda snapshot: ( + snapshot.get("kind") == "server" + and len(snapshot.get("clients", [])) == 1 + and snapshot["clients"][0]["bytes_sent"] > 0 + ), + 2.0, + description="server-side client counters", + ) + assert server_snapshot is not None + assert server_snapshot["clients"][0]["current_rate_mbps"] == target - server, port = start_server(server_args, "server1") - client = spawn( - CLIENT, - ["--server", "127.0.0.1", "--port", str(port), "--response", "0.3", - "--stats-interval", "1", "--psk-file", str(psk_file), - *_fast_client_timings()], - "client", +def test_unvalidated_udp_gets_only_bounded_challenge(start_server, psk_file): + server, port = start_server( + lambda selected_port: _fixed_server_args(selected_port, psk_file), + "probe-server", ) - assert wait_for(client, "Rx:", 10.0), read_log(client) + destination = ("127.0.0.1", port) + probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + replay_probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + probe.bind(("127.0.0.1", 0)) + replay_probe.bind(("127.0.0.1", 0)) + probe.settimeout(0.25) + replay_probe.settimeout(0.25) + try: + hello = encode_frame( + MessageType.HELLO, + b"p" * NONCE_SIZE, + ZERO_NONCE, + 1, + TEST_PSK, + ) + malformed = (b"x", hello[:-1], hello + b"x", hello[:-1] + b"!") + sent_bytes = 0 + for datagram in malformed: + sent_bytes += probe.sendto(datagram, destination) + with pytest.raises(socket.timeout): + probe.recvfrom(65_535) + + sent_bytes += probe.sendto(hello, destination) + challenge_datagram, source = probe.recvfrom(65_535) + challenge = decode_frame(challenge_datagram, TEST_PSK) + assert source == destination + assert challenge.message_type is MessageType.CHALLENGE + assert len(challenge_datagram) <= sent_bytes * 3 + + replayed_auth = encode_frame( + MessageType.AUTH, + challenge.client_nonce, + challenge.session_nonce, + challenge.sequence + 1, + TEST_PSK, + payload=challenge.payload, + ) + replay_probe.sendto(replayed_auth, destination) + replay_probe.sendto(replayed_auth, destination) + with pytest.raises(socket.timeout): + replay_probe.recvfrom(65_535) + with pytest.raises(socket.timeout): + probe.recvfrom(65_535) - # Phase 2: kill the server; the client must detect loss and must NOT falsely - # report a reconnect while the server is down. - stop_process(server) - assert wait_for(client, "Connection lost", 5.0), read_log(client) - downtime = read_log(client).split("Connection lost", 1)[1] - assert "Reconnected successfully" not in downtime, read_log(client) + snapshot = wait_for_snapshot( + server, + lambda item: item.get("kind") == "server" and not item.get("clients"), + 2.0, + description="empty validated-client set", + ) + assert snapshot is not None + assert "New client connected" not in read_log(server) + finally: + probe.close() + replay_probe.close() - # Phase 3: restart the server; the client must reconnect. - reconnect_offset = client.mark_log() - server2, _ = start_server(server_args, "server2", port=port) - assert wait_for( - client, "Reconnected successfully", 8.0, offset=reconnect_offset - ), read_log(client, offset=reconnect_offset) - assert server2.process.poll() is None - assert "Receive error" not in read_log(client, offset=reconnect_offset) +def test_wrong_psk_client_remains_unregistered( + spawn, start_server, psk_file, tmp_path +): + server, port = start_server( + lambda selected_port: _fixed_server_args(selected_port, psk_file), + "wrong-key-server", + ) + wrong_psk = tmp_path / "wrong.psk" + wrong_psk.write_bytes(b"w" * 32) + wrong_psk.chmod(0o600) + client = spawn(CLIENT, _client_args(port, wrong_psk), "wrong-key-client") + + assert wait_for(client, "Handshake HELLO sent", 3.0), read_log(client) + client_snapshot = wait_for_snapshot( + client, + lambda snapshot: ( + snapshot.get("kind") == "client" + and snapshot.get("handshake_accepted") is False + and snapshot.get("totals", {}).get("bytes_received") == 0 + and snapshot.get("timestamp", 0) > 0 + ), + 2.0, + description="unaccepted wrong-key client", + ) + assert client_snapshot is not None + server_snapshot = wait_for_snapshot( + server, + lambda snapshot: snapshot.get("kind") == "server" and not snapshot["clients"], + 2.0, + description="server without wrong-key client", + ) + assert server_snapshot is not None + assert "New client connected" not in read_log(server) + assert "Authenticated session accepted" not in read_log(client) -def test_fixed_rate_is_not_inflated(spawn, start_server, psk_file): - """Characterization: --mbps 1 emits on the order of 1 Mbit/s, not ~8.8. - The legacy pattern generator legitimately scales the commanded rate - (bursts up to 4x for single windows), so this only pins the gross unit - error: the old bits-as-bytes budget inflated the average ~8.8x. - """ +def test_bidirectional_response_ratio_is_accounted( + spawn, start_server, psk_file +): + response = 0.3 server, port = start_server( - lambda selected_port: [ - "--host", "127.0.0.1", "--port", str(selected_port), - "--mbps", "1", "--stats-interval", "1", - "--psk-file", str(psk_file), - ], - "server", + lambda selected_port: _fixed_server_args( + selected_port, psk_file, target=2.0 + ), + "bidirectional-server", ) - client = spawn( CLIENT, - [ - "--server", "127.0.0.1", "--port", str(port), - "--stats-interval", "1", "--psk-file", str(psk_file), - ], - "client", + _client_args( + port, + psk_file, + "--response", str(response), "--padding", "random", + ), + "bidirectional-client", ) - assert wait_for(client, "Rx:", 10.0), read_log(client) - - time.sleep(6) - rates = [ - float(m) - for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(server)) - ] - assert rates, read_log(server) - mean_rate = sum(rates) / len(rates) - assert 0.1 <= mean_rate <= 3.0, rates - assert max(rates) <= 5.0, rates + client_snapshot = wait_for_snapshot( + client, + lambda snapshot: ( + _is_client_data(snapshot, minimum_bytes=200_000) + and snapshot["totals"]["bytes_sent"] > 40_000 + and response * 0.85 <= snapshot["uplink_ratio"] <= response + ), + 5.0, + description="accounted bidirectional ratio", + ) + assert client_snapshot is not None + server_snapshot = wait_for_snapshot( + server, + lambda snapshot: ( + snapshot.get("kind") == "server" + and len(snapshot.get("clients", [])) == 1 + and snapshot["clients"][0]["bytes_received"] > 40_000 + ), + 2.0, + description="server-side uplink accounting", + ) + assert server_snapshot is not None -def test_floating_rate_stays_within_bounds(spawn, start_server, psk_file): - """Characterization: the emitted server rate stays within a slack of [min,max]. - (The old realistic-pattern runner's boundary-coverage "quality" scoring is - intentionally not ported: it rewards exact-boundary teleporting, a behaviour a - later stage removes.) - """ - lo, hi = 2.0, 6.0 - server, port = start_server( - lambda selected_port: [ - "--host", "127.0.0.1", "--port", str(selected_port), - "--shape-mode", "rate", "--min-mbps", str(lo), - "--max-mbps", str(hi), "--stats-interval", "1", - "--psk-file", str(psk_file), - ], - "server", - ) +def test_reconnection_after_server_restart(spawn, start_server, psk_file): + def server_args(selected_port): + return _fixed_server_args(selected_port, psk_file, target=1.5) + server, port = start_server(server_args, "reconnect-server-1") client = spawn( CLIENT, - [ - "--server", "127.0.0.1", "--port", str(port), - "--stats-interval", "1", "--psk-file", str(psk_file), - ], - "client", + _client_args(port, psk_file, *_fast_client_timings()), + "reconnect-client", ) - assert wait_for(client, "Rx:", 10.0), read_log(client) + connected = wait_for_snapshot( + client, + lambda snapshot: _is_client_data(snapshot, minimum_bytes=20_000), + 5.0, + description="initial connected phase", + ) + assert connected is not None - time.sleep(6) - rates = [ - float(m) - for m in re.findall(r"Rate:\s*([0-9.]+)\s*Mbps", read_log(server)) - ] - assert rates, read_log(server) - assert min(rates) >= 0.0 - # Generous slack: this only guards against runaway rate, not shape quality. - assert max(rates) <= hi * 1.75, rates + stop_process(server) + assert wait_for(client, "Connection lost", 3.0), read_log(client) + downtime_offset = client.mark_log() + time.sleep(0.45) + assert "Reconnected successfully" not in read_log( + client, offset=downtime_offset + ) + + reconnect_offset = client.mark_log() + server2, _ = start_server(server_args, "reconnect-server-2", port=port) + assert wait_for( + client, "Reconnected successfully", 5.0, offset=reconnect_offset + ), read_log(client, offset=reconnect_offset) + resumed = wait_for_snapshot( + client, + lambda snapshot: ( + _is_client_data(snapshot) + and snapshot["totals"]["bytes_received"] + > connected["totals"]["bytes_received"] + ), + 3.0, + offset=reconnect_offset, + description="data after reconnect", + ) + assert resumed is not None + assert server2.process.poll() is None + assert "Receive error" not in read_log(client, offset=reconnect_offset) -def test_two_clients_share_total_cap_with_bounded_fairness( +def test_two_clients_share_total_cap_with_independent_state( spawn, start_server, psk_file ): + target = 1.0 cap = 1.5 server, port = start_server( - lambda selected_port: [ - "--host", "127.0.0.1", "--port", str(selected_port), - "--mbps", "1", "--max-total-mbps", str(cap), - "--stats-interval", "1", "--psk-file", str(psk_file), - ], + lambda selected_port: _fixed_server_args( + selected_port, psk_file, target=target, total_cap=cap + ), "fair-server", ) clients = [ - spawn( - CLIENT, - [ - "--server", "127.0.0.1", "--port", str(port), - "--stats-interval", "1", "--psk-file", str(psk_file), - ], - f"fair-client-{index}", - ) + spawn(CLIENT, _client_args(port, psk_file), f"fair-client-{index}") for index in range(2) ] for client in clients: - assert wait_for(client, "Authenticated session accepted", 5.0), read_log( - client + snapshot = wait_for_snapshot( + client, + lambda item: _is_client_data(item, minimum_bytes=10_000), + 5.0, + description="independent client data", ) + assert snapshot is not None + + first = wait_for_snapshot( + server, + lambda snapshot: ( + snapshot.get("kind") == "server" + and len(snapshot.get("clients", [])) == 2 + and all(client["bytes_sent"] > 0 for client in snapshot["clients"]) + ), + 3.0, + description="two server clients", + ) + assert first is not None + second = wait_for_snapshot( + server, + lambda snapshot: ( + snapshot.get("kind") == "server" + and len(snapshot.get("clients", [])) == 2 + and snapshot["timestamp"] >= first["timestamp"] + 1.0 + ), + 3.0, + description="two-client cap window", + ) + assert second is not None - time.sleep(5) - client_rates = [ - last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") for client in clients + first_by_address = {tuple(item["address"]): item for item in first["clients"]} + second_by_address = {tuple(item["address"]): item for item in second["clients"]} + assert len(second_by_address) == 2 + assert second_by_address.keys() == first_by_address.keys() + elapsed = second["timestamp"] - first["timestamp"] + rates = [ + ( + item["bytes_sent"] - first_by_address[address]["bytes_sent"] + ) + * 8 + / (elapsed * 1_000_000) + for address, item in second_by_address.items() ] - total_rate = last_match(server, r"Total Rate:\s*([0-9.]+)\s*Mbps") - assert all(rate is not None and rate > 0.4 for rate in client_rates) - assert total_rate is not None and total_rate <= cap * 1.15 - assert abs(client_rates[0] - client_rates[1]) <= max(client_rates) * 0.35 - assert "Per-client:" in read_log(server) + assert all(item["current_rate_mbps"] == target for item in second["clients"]) + assert all(rate >= target * 0.55 for rate in rates), rates + assert sum(rates) <= cap * 1.15, rates + assert abs(rates[0] - rates[1]) <= max(rates) * 0.25, rates -def test_raw_probe_gets_only_bounded_challenge(start_server, psk_file): +def test_floating_rate_and_observed_egress_stay_bounded( + spawn, start_server, psk_file +): + lower, upper = 1.0, 2.0 server, port = start_server( lambda selected_port: [ - "--host", "127.0.0.1", "--port", str(selected_port), - "--mbps", "1", "--stats-interval", "1", - "--psk-file", str(psk_file), + *_server_base(selected_port, psk_file), + "--shape-mode", "rate", + "--min-mbps", str(lower), "--max-mbps", str(upper), ], - "probe-server", + "floating-server", ) - probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - probe.bind(("127.0.0.1", 0)) - probe.settimeout(0.5) - try: - probe.sendto(b"x", ("127.0.0.1", port)) - with pytest.raises(socket.timeout): - probe.recvfrom(65535) - - hello = encode_frame( - MessageType.HELLO, - b"p" * NONCE_SIZE, - ZERO_NONCE, - 1, - TEST_PSK, - ) - probe.sendto(hello, ("127.0.0.1", port)) - challenge, source = probe.recvfrom(65535) - assert source == ("127.0.0.1", port) - assert len(challenge) <= len(hello) * 3 - assert decode_frame(challenge, TEST_PSK).message_type is MessageType.CHALLENGE - - with pytest.raises(socket.timeout): - probe.recvfrom(65535) - assert "New client connected" not in read_log(server) - finally: - probe.close() + client = spawn(CLIENT, _client_args(port, psk_file), "floating-client") + first = wait_for_snapshot( + client, + lambda snapshot: _is_client_data(snapshot, minimum_bytes=25_000), + 5.0, + description="floating-rate data", + ) + assert first is not None + second = wait_for_snapshot( + client, + lambda snapshot: ( + _is_client_data(snapshot) + and snapshot["timestamp"] >= first["timestamp"] + 1.0 + ), + 3.0, + description="floating-rate observation window", + ) + assert second is not None + observed = _rate_between(first, second) + assert lower * 0.85 <= observed <= upper * 1.15, observed + + server_snapshots = read_snapshots(server) + targets = [ + client_state["current_rate_mbps"] + for snapshot in server_snapshots + for client_state in snapshot.get("clients", []) + ] + assert targets + assert all(lower <= target <= upper for target in targets) -def test_wrong_psk_client_remains_unregistered( - spawn, start_server, psk_file, tmp_path +@pytest.mark.parametrize( + "profile", ["web", "video", "voip", "file", "gaming", "mixed"] +) +def test_each_profile_emits_authenticated_data( + profile, spawn, start_server, psk_file ): server, port = start_server( - lambda selected_port: [ - "--host", "127.0.0.1", "--port", str(selected_port), - "--mbps", "1", "--stats-interval", "1", - "--psk-file", str(psk_file), - ], - "wrong-key-server", + lambda selected_port: _profile_server_args( + selected_port, psk_file, profile=profile + ), + f"profile-{profile}-server", ) - wrong_psk = tmp_path / "wrong.psk" - wrong_psk.write_bytes(b"w" * 32) - wrong_psk.chmod(0o600) client = spawn( CLIENT, - [ - "--server", "127.0.0.1", "--port", str(port), - "--stats-interval", "1", "--psk-file", str(wrong_psk), - ], - "wrong-key-client", + _client_args(port, psk_file), + f"profile-{profile}-client", ) - assert wait_for(client, "Handshake HELLO sent", 5.0), read_log(client) - time.sleep(2) - assert "New client connected" not in read_log(server) - assert "Authenticated session accepted" not in read_log(client) - assert last_match(client, r"Rx:\s*([0-9.]+)\s*Mbps") == 0.0 - - -def test_sigterm_stops_both_processes_cleanly(spawn, start_server, psk_file): + snapshot = wait_for_snapshot( + client, + lambda item: _is_client_data(item), + 5.0, + description=f"{profile} profile data", + ) + assert snapshot is not None + server_snapshot = wait_for_snapshot( + server, + lambda item: ( + item.get("kind") == "server" + and item.get("pattern") == f"experimental-profile:{profile}" + and len(item.get("clients", [])) == 1 + ), + 2.0, + description=f"{profile} server snapshot", + ) + assert server_snapshot is not None + + +def _socket_inodes(pid): + inodes = set() + for descriptor in (Path("/proc") / str(pid) / "fd").iterdir(): + try: + target = os.readlink(descriptor) + except FileNotFoundError: + continue + if target.startswith("socket:[") and target.endswith("]"): + inodes.add(target[8:-1]) + return inodes + + +def _udp_socket_rows(pid): + socket_inodes = _socket_inodes(pid) + rows = [] + for table_name in ("udp", "udp6"): + table = Path("/proc") / str(pid) / "net" / table_name + for line in table.read_text().splitlines()[1:]: + fields = line.split() + if len(fields) < 10 or fields[9] not in socket_inodes: + continue + local_host, local_port = fields[1].rsplit(":", 1) + rows.append( + { + "family": table_name, + "inode": fields[9], + "local_host": local_host, + "local_port": int(local_port, 16), + } + ) + return socket_inodes, rows + + +@pytest.mark.skipif(not Path("/proc/self/net/udp").exists(), reason="requires procfs") +def test_deployment_smoke_has_only_declared_udp_sockets( + spawn, start_server, psk_file +): server, port = start_server( - lambda selected_port: [ - "--host", "127.0.0.1", "--port", str(selected_port), - "--mbps", "1", "--stats-interval", "1", - "--psk-file", str(psk_file), - ], - "signal-server", + lambda selected_port: _fixed_server_args( + selected_port, psk_file, target=1.0 + ), + "deployment-server", ) client = spawn( CLIENT, - [ - "--server", "127.0.0.1", "--port", str(port), - "--stats-interval", "1", "--psk-file", str(psk_file), - ], - "signal-client", + _client_args(port, psk_file, "--response", "0.1"), + "deployment-client", + ) + snapshot = wait_for_snapshot( + client, + lambda item: ( + _is_client_data(item, minimum_bytes=10_000) + and item["totals"]["bytes_sent"] > 0 + ), + 5.0, + description="declared bidirectional flow", + ) + assert snapshot is not None + assert snapshot["server_address"] == ["127.0.0.1", port] + + server_inodes, server_udp = _udp_socket_rows(server.process.pid) + client_inodes, client_udp = _udp_socket_rows(client.process.pid) + assert len(server_inodes) == len(server_udp) == 1 + assert len(client_inodes) == len(client_udp) == 1 + assert server_udp[0]["family"] == "udp" + assert server_udp[0]["local_port"] == port + assert client_udp[0]["family"] == "udp" + assert client_udp[0]["local_port"] not in (0, port) + + +def test_sigterm_stops_both_process_groups_cleanly( + spawn, start_server, psk_file +): + server, port = start_server( + lambda selected_port: _fixed_server_args(selected_port, psk_file), + "signal-server", ) - assert wait_for(client, "Authenticated session accepted", 5.0), read_log(client) + client = spawn(CLIENT, _client_args(port, psk_file), "signal-client") + assert wait_for_snapshot( + client, + lambda snapshot: _is_client_data(snapshot), + 5.0, + description="data before SIGTERM", + ) is not None for spawned in (client, server): - os.killpg(spawned.process.pid, signal.SIGTERM) - assert spawned.process.wait(timeout=5) == 0, read_log(spawned) + process_group_id = spawned.process.pid + os.killpg(process_group_id, signal.SIGTERM) + assert spawned.process.wait(timeout=3) == 0, read_log(spawned) + assert not process_group_exists(process_group_id) assert "Traceback" not in read_log(spawned) diff --git a/traffic-masking/traffic_masking_client.py b/traffic-masking/traffic_masking_client.py index 96d1a1b..bb71a43 100644 --- a/traffic-masking/traffic_masking_client.py +++ b/traffic-masking/traffic_masking_client.py @@ -7,6 +7,7 @@ """Authenticated UDP cover-traffic client with optional uplink responses.""" import argparse +import json import math import os import random @@ -75,6 +76,7 @@ def __init__( padding="none", mtu=1200, stats_interval=5.0, + stats_json=False, rng=None, byte_source=None, psk=None, @@ -210,6 +212,7 @@ def __init__( self.control_receive_sequence = -1 self.handshake_accepted = False self.stats_interval = stats_interval + self.stats_json = bool(stats_json) self.uplink_budget = RatioBudget(response_ratio) self.padder = PayloadPadder( strategy=padding, @@ -726,14 +729,47 @@ def stats_loop(self): ) / elapsed send_pps = (current.packets_sent - previous.packets_sent) / elapsed conn_status = "connected" if current.connected else "disconnected" - print( - f"[STATS client window] Rx: {recv_mbps:.2f} Mbps " - f"({recv_pps:.0f} pps) | " - f"Tx: {send_mbps:.2f} Mbps ({send_pps:.0f} pps) | " - f"Uplink ratio: {current.uplink_ratio:.3f} | " - f"Status: {conn_status}", - flush=True, - ) + if self.stats_json: + payload = { + "kind": "client", + "timestamp": current.timestamp, + "connected": current.connected, + "handshake_accepted": current.handshake_accepted, + "server_address": ( + list(current.server_address) + if current.server_address is not None + else None + ), + "totals": { + "bytes_received": current.bytes_received, + "bytes_sent": current.bytes_sent, + "packets_received": current.packets_received, + "packets_sent": current.packets_sent, + }, + "window": { + "duration_seconds": elapsed, + "rx_mbps": recv_mbps, + "tx_mbps": send_mbps, + "rx_pps": recv_pps, + "tx_pps": send_pps, + }, + "received_rate_mbps": current.received_rate_mbps, + "uplink_ratio": current.uplink_ratio, + } + print( + "[SNAPSHOT] " + + json.dumps(payload, sort_keys=True, separators=(",", ":")), + flush=True, + ) + else: + print( + f"[STATS client window] Rx: {recv_mbps:.2f} Mbps " + f"({recv_pps:.0f} pps) | " + f"Tx: {send_mbps:.2f} Mbps ({send_pps:.0f} pps) | " + f"Uplink ratio: {current.uplink_ratio:.3f} | " + f"Status: {conn_status}", + flush=True, + ) previous = current def stop(self, join_timeout=2.0): @@ -777,6 +813,11 @@ def main(): default=_env_default("TRAFFIC_MASKING_STATS_INTERVAL", 5.0), help="Stats print interval in seconds", ) + parser.add_argument( + "--stats-json", + action="store_true", + help="Emit machine-readable runtime snapshots", + ) parser.add_argument( "--keepalive-interval", type=float, @@ -829,6 +870,7 @@ def main(): padding=args.padding, mtu=args.mtu, stats_interval=args.stats_interval, + stats_json=args.stats_json, psk=psk, insecure_diagnostic=args.insecure_diagnostic, keepalive_jitter=args.keepalive_jitter, diff --git a/traffic-masking/traffic_masking_server.py b/traffic-masking/traffic_masking_server.py index 67dee4c..29df42c 100644 --- a/traffic-masking/traffic_masking_server.py +++ b/traffic-masking/traffic_masking_server.py @@ -8,6 +8,7 @@ import argparse import hashlib +import json import math import os import random @@ -165,6 +166,7 @@ def __init__( padding="none", mtu=1200, stats_interval=5.0, + stats_json=False, psk=None, insecure_diagnostic=False, max_clients=16, @@ -305,6 +307,7 @@ def __init__( "packets_sent": 0, } self.stats_interval = stats_interval + self.stats_json = bool(stats_json) if shape_mode == "profile": try: self.profile = ( @@ -956,6 +959,7 @@ def stats_loop(self): ) previous_clients = {item.address: item for item in previous.clients} client_rates = [] + client_rates_structured = [] for client in current.clients: prior = previous_clients.get(client.address) prior_bytes = prior.bytes_sent if prior is not None else 0 @@ -972,14 +976,49 @@ def stats_loop(self): f"{client.address[0]}:{client.address[1]}=" f"{client_mbps:.2f}Mbps{target_text}" ) + client_rates_structured.append((client, client_mbps)) per_client = ";".join(client_rates) or "none" - print( - f"[STATS] Clients: {current.client_count} | " - f"Total Rate: {mbps:.2f} Mbps | " - f"Total PPS: {pps:.0f} | " - f"Per-client: {per_client} | Pattern: {pattern_desc}", - flush=True, - ) + if self.stats_json: + payload = { + "kind": "server", + "timestamp": current.timestamp, + "totals": { + "bytes_sent": current.bytes_sent, + "packets_sent": current.packets_sent, + }, + "window": { + "duration_seconds": time_delta, + "mbps": mbps, + "pps": pps, + }, + "clients": [ + { + "address": list(client.address), + "last_seen": client.last_seen, + "bytes_received": client.bytes_received, + "packets_received": client.packets_received, + "bytes_sent": client.bytes_sent, + "packets_sent": client.packets_sent, + "current_rate_mbps": client.current_rate_mbps, + "window_mbps": client_mbps, + } + for client, client_mbps in client_rates_structured + ], + "pattern": pattern_desc, + } + print( + "[SNAPSHOT] " + + json.dumps(payload, sort_keys=True, separators=(",", ":")), + flush=True, + ) + else: + print( + f"[STATS] Clients: {current.client_count} | " + f"Total Rate: {mbps:.2f} Mbps | " + f"Total PPS: {pps:.0f} | " + f"Per-client: {per_client} | Pattern: {pattern_desc}", + flush=True, + ) previous = current def stop(self, join_timeout=2.0): @@ -1046,6 +1085,11 @@ def main(): default=_env_default("TRAFFIC_MASKING_STATS_INTERVAL", 5.0), help="Stats print interval in seconds", ) + parser.add_argument( + "--stats-json", + action="store_true", + help="Emit machine-readable runtime snapshots", + ) auth_group = parser.add_mutually_exclusive_group() auth_group.add_argument( "--psk-file", @@ -1090,6 +1134,7 @@ def main(): padding=args.padding, mtu=args.mtu, stats_interval=args.stats_interval, + stats_json=args.stats_json, psk=psk, insecure_diagnostic=args.insecure_diagnostic, max_clients=args.max_clients, From 2ad7c80a36f08191d87ede0abbe0db332c6e55f2 Mon Sep 17 00:00:00 2001 From: kogeler <25884155+kogeler@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:26:36 +0300 Subject: [PATCH 10/10] traffic-masking: release version 2.0.0 --- traffic-masking/.version | 2 +- traffic-masking/AGENTS.md | 2 + traffic-masking/CHANGELOG.md | 139 +++++++++++++++-------------------- traffic-masking/Dockerfile | 40 ++++------ traffic-masking/EXAMPLES.md | 14 +++- traffic-masking/README.md | 25 +++++-- traffic-masking/SUMMARY.md | 3 + traffic-masking/test_cli.py | 15 +++- 8 files changed, 126 insertions(+), 114 deletions(-) diff --git a/traffic-masking/.version b/traffic-masking/.version index a6a3a43..227cea2 100644 --- a/traffic-masking/.version +++ b/traffic-masking/.version @@ -1 +1 @@ -1.0.4 \ No newline at end of file +2.0.0 diff --git a/traffic-masking/AGENTS.md b/traffic-masking/AGENTS.md index 5f2666d..bb03a5a 100644 --- a/traffic-masking/AGENTS.md +++ b/traffic-masking/AGENTS.md @@ -52,3 +52,5 @@ spike, or guarantee a target aggregate when user traffic already exceeds it. - Run `make test-fast`, `make lint`, and `make test-live` for changes that affect process or network behavior. - Runtime code must remain importable with only the standard library. +- Treat `.version` as the release source of truth and pass it to container builds; + the Dockerfile must reject mismatched image metadata. diff --git a/traffic-masking/CHANGELOG.md b/traffic-masking/CHANGELOG.md index 4ed0d48..b2b7290 100644 --- a/traffic-masking/CHANGELOG.md +++ b/traffic-masking/CHANGELOG.md @@ -1,10 +1,65 @@ # Changelog -All notable changes to the Traffic Masking System will be documented in this file. +All notable changes to Traffic Masking will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-07-22 + +This release replaces the previously advertised advanced stack with one tested, +authenticated cover-traffic path. Historical entries below describe older +releases and are not claims about the current implementation. + +### Security + +- Added a versioned, length-checked UDP control and DATA protocol authenticated + with HMAC-SHA256 and direction-specific session keys. +- Added source-bound expiring challenge cookies, replay protection, bounded + pre-validation replies, handshake limits, client limits, and an aggregate + egress cap. +- Production mode now requires a restrictive 32-4096 byte PSK file. The explicit + `--insecure-diagnostic` mode uses a public built-in HMAC key and is only for + isolated testing. +- Unknown, malformed, wrong-key, and replayed datagrams cannot enroll a cover + traffic destination. + +### Changed + +- Split shaping into explicit `rate` and `profile` modes. Fixed/floating rate + values are per validated client; profile `--max-mbps` is a ceiling only. +- Defined Mbps as decimal framed application-datagram throughput and made MTU, + packetization, padding, per-client pacing, and aggregate pacing account the + same byte layer. +- Replaced boundary-snapping rate patterns with independent slope-limited rate + state per client and round-robin service under the server-wide cap. +- Changed the default client response ratio to `0.0`. Nonzero response traffic, + DATA framing, padding, and keepalives now share one measured uplink budget. +- Added configurable health/reconnect timings, immutable runtime snapshots, + optional JSON statistics, synchronized state, and bounded SIGINT/SIGTERM + shutdown of non-daemon workers. +- Reduced the runtime to the Python standard library and updated the container + base to Python 3.14 Alpine running as an unprivileged user. + +### Removed + +- Removed `--advanced`, `--header`, `--entropy`, and `--uplink-profile` without a + compatibility parser. Configurations containing them now fail as invalid. +- Removed the unconnected enhanced timing, correlation, entropy, state-machine, + and ML-resistance modules and their unsupported security claims. +- Removed the separately executable legacy test runners; all tests are native + pytest tests with bounded live-process coverage. + +### Operations + +- Added hardened systemd templates using credential files and documented PSK + rotation by coordinated stop, replacement, and restart. +- Added observer-trace metrics with explicit capture point, direction, connection, + byte layer, and encapsulation overhead. Packet acquisition and outer encrypted + multiplex validation remain deployment responsibilities. +- Existing service definitions must use the current rate/profile CLI, provide a + PSK file, then be reloaded and restarted. No state or data migration is needed. + ## [1.0.4] - 2025-02-12 ### Fixed @@ -45,81 +100,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.0] - 2024-12-20 ### Added - -#### Core Features -- UDP-based cover traffic generation system with server and client components -- Dynamic traffic patterns: constant bitrate, burst, wave, random walk, and media-like patterns -- Variable packet sizes and inter-packet intervals with correlation awareness -- Bidirectional traffic flow with adaptive response ratio -- High throughput capability (8-10 Mbps achieved on modest hardware) +- Initial UDP cover-traffic server and client. - Floating rate mode with configurable minimum and maximum traffic rates -- Natural traffic variations that follow realistic rate change patterns - -#### Protocol Mimicry -- Traffic profiles: web browsing, video streaming, VoIP, file transfer, gaming, mixed -- Protocol-specific packet generation patterns -- Session phase modeling and lifecycle simulation - -#### Advanced Obfuscation -- Dynamic packet obfuscation with multiple strategies -- Padding strategies: random, fixed buckets, progressive, none -- Pseudo-headers support: RTP-like and QUIC-like headers -- MTU-aware fragmentation -- Entropy control for payload generation (0.0-1.0 scale) -- Timing jitter and delay variation - -#### Enhanced Modules -- **Adaptive Timing Model**: Realistic network delay simulation with congestion modeling, correlated jitter, packet loss simulation -- **Correlation Breaker**: Markov chain-based packet size generation to disrupt statistical analysis -- **ML-Resistant Generator**: Adversarial packet generation to evade machine learning detection -- **Entropy Enhancer**: Realistic encrypted payload generation mimicking various cipher types -- **Protocol State Machines**: Accurate protocol behavior simulation (TLS, QUIC, WebRTC, SSH, HTTP/2, HTTP/3) - -#### Operational Features -- Multi-client support in server mode -- Real-time statistics reporting with configurable intervals -- Batch packet processing for improved throughput -- Socket buffer optimization for high-speed operation -- Graceful degradation when enhanced modules unavailable - -### Performance -- **Throughput**: 8-10 Mbps sustained rate (112-125% efficiency) -- **Stability**: Consistent performance over extended periods -- **CPU Usage**: Optimized with batch processing and selective enhancement -- **Memory**: ~50MB typical usage -- **Latency**: Minimal added delay with adaptive timing - -### Configuration -- Command-line interface with extensive options -- Docker support with included Dockerfile -- Systemd service configuration examples -- Integration examples with VPN solutions (WireGuard) - -### Testing -- Comprehensive test suite with automated testing -- Performance benchmarking tools -- Real data transmission verification -- Progress monitoring during tests - -### Documentation -- Complete README with usage examples and best practices -- Performance benchmarks and optimization tips -- Troubleshooting guide -- Security considerations documentation - -### Security Features -- Designed to defeat heuristic and ML-based traffic analysis -- Timing correlation attack resistance -- Size-based traffic analysis prevention -- Continuous pattern variation to prevent fingerprinting - -## [Unreleased] - -### Planned -- Traffic-aware adaptive mode with tunnel interface monitoring -- Aggregate profile validation and real-time adjustment -- Upload/download ratio compensation -- Realistic session scheduling with idle periods -- Performance optimization with Cython/Rust modules -- Additional protocol profiles -- Built-in traffic analysis tools +- Bidirectional response traffic and experimental web, video, VoIP, file, + gaming, and mixed profiles. +- Payload padding, application packetization, multi-client operation, runtime + statistics, Docker packaging, and systemd examples. diff --git a/traffic-masking/Dockerfile b/traffic-masking/Dockerfile index 155230b..a4679cf 100644 --- a/traffic-masking/Dockerfile +++ b/traffic-masking/Dockerfile @@ -1,45 +1,33 @@ # Copyright © 2025 kogeler # SPDX-License-Identifier: Apache-2.0 -# Use the official Python image based on Alpine FROM python:3.14-alpine -LABEL maintainer="kogeler" -LABEL description="UDP Traffic Masking System" -LABEL version="1.0.2" +ARG VERSION -# Optimize Python for containers ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PYTHONUNBUFFERED=1 -# Create a non-privileged user and group (pyuser) -# -D: do not create a home directory, -G: set the group -RUN addgroup -g 1000 pyuser && adduser -D -G pyuser -u 1000 pyuser +RUN addgroup -g 1000 pyuser \ + && adduser -D -G pyuser -u 1000 pyuser \ + && install -d -o pyuser -g pyuser -m 0700 /run/secrets WORKDIR /app -# Install dependencies -COPY requirements.txt /app/ -RUN pip install --no-cache-dir -r requirements.txt +COPY .version /app/.version +RUN test -n "${VERSION}" && test "${VERSION}" = "$(cat /app/.version)" -# Copy application files -COPY control_protocol.py masking_lib.py observer_metrics.py traffic_masking_server.py traffic_masking_client.py /app/ - -# Copy documentation -COPY *.md /app/ +LABEL org.opencontainers.image.authors="kogeler" \ + org.opencontainers.image.description="Experimental authenticated UDP cover-traffic generator" \ + org.opencontainers.image.source="https://github.com/kogeler/tooling/tree/main/traffic-masking" \ + org.opencontainers.image.version="${VERSION}" -# Set ownership -RUN chown -R pyuser:pyuser /app +COPY control_protocol.py masking_lib.py observer_metrics.py traffic_masking_server.py traffic_masking_client.py /app/ -# Switch to the non-privileged user -USER pyuser +USER 1000:1000 -# Expose UDP port EXPOSE 8888/udp +STOPSIGNAL SIGTERM -# Default entrypoint ENTRYPOINT ["python"] - -# Default command shows usage CMD ["-c", "print('Usage (mount a mode 0600 PSK at /run/secrets/traffic-masking.psk):\\n Server: python traffic_masking_server.py --min-mbps 2 --max-mbps 8 --psk-file /run/secrets/traffic-masking.psk\\n Client: python traffic_masking_client.py --server --psk-file /run/secrets/traffic-masking.psk')"] diff --git a/traffic-masking/EXAMPLES.md b/traffic-masking/EXAMPLES.md index f7f2c91..a076931 100644 --- a/traffic-masking/EXAMPLES.md +++ b/traffic-masking/EXAMPLES.md @@ -98,15 +98,20 @@ of `0.2`. ## Docker ```bash -docker build -t traffic-masking . +VERSION="$(cat .version)" +docker build --build-arg VERSION="$VERSION" -t "traffic-masking:$VERSION" . docker run --network host \ --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_server.py \ + "traffic-masking:$VERSION" traffic_masking_server.py \ --shape-mode profile --profile mixed --max-mbps 8 --padding random \ --psk-file /run/secrets/traffic-masking.psk ``` +With rootless Podman, use the same command as `podman run` and add +`--userns=keep-id:uid=1000,gid=1000` so container UID 1000 can read the +host-owned mode `0600` PSK. + ## Monitoring ```bash @@ -114,4 +119,9 @@ sudo tcpdump -i any -n udp port 8888 -c 100 grep "Total Rate:" server.log grep "Per-client:" server.log grep "Uplink ratio:" client.log +grep '^\[SNAPSHOT\] ' structured.log ``` + +The log commands inspect application counters. The direct UDP capture is useful +for diagnostics but does not represent an enclosing encrypted transport; capture +that transport separately at the declared observer boundary. diff --git a/traffic-masking/README.md b/traffic-masking/README.md index cdf90c2..0c14fc0 100644 --- a/traffic-masking/README.md +++ b/traffic-masking/README.md @@ -33,9 +33,13 @@ The runtime uses only the Python standard library. ```bash python3 -m venv venv source venv/bin/activate -pip install -r requirements.txt +python traffic_masking_server.py --help +python traffic_masking_client.py --help ``` +`requirements.txt` is the intentionally empty freeze of the runtime environment. +The canonical release version is stored in `.version`. + Create one binary PSK and install the same file on both endpoints: ```bash @@ -103,7 +107,9 @@ payload encryption; confidentiality still depends on the external transport. `--insecure-diagnostic` uses a public built-in key. It is intended only for isolated local diagnostics and remains subject to handshake, client, and rate -limits. +limits. `--max-clients` bounds validated sessions, +`--max-handshakes-per-second` bounds handshake work, and `--max-total-mbps` +bounds aggregate server egress. ## Timing And Metrics @@ -155,16 +161,25 @@ replace a capture at the enclosing encrypted transport boundary. ## Docker ```bash -docker build -t traffic-masking . +VERSION="$(cat .version)" +docker build --build-arg VERSION="$VERSION" -t "traffic-masking:$VERSION" . docker run --network host \ --mount type=bind,src="$PWD/traffic-masking.psk",dst=/run/secrets/traffic-masking.psk,readonly \ - traffic-masking traffic_masking_server.py \ + "traffic-masking:$VERSION" traffic_masking_server.py \ --shape-mode rate --min-mbps 2 --max-mbps 8 \ --psk-file /run/secrets/traffic-masking.psk ``` The mounted secret must be readable by container UID 1000 while retaining mode -`0400` or `0600` and no group/other permission bits. +`0400` or `0600` and no group/other permission bits. The build fails when its +version argument differs from `.version`. The image intentionally has no +healthcheck: process liveness would not prove authenticated data flow, while an +active protocol probe would create session state. + +For rootless Podman, add `--userns=keep-id:uid=1000,gid=1000` to `podman run` so +the host-owned mode `0600` bind mount maps to the image user. With Docker Engine +without user-namespace remapping, ensure the mounted file is owned by numeric UID +1000; other mappings require an equivalent ownership adjustment. ## Systemd diff --git a/traffic-masking/SUMMARY.md b/traffic-masking/SUMMARY.md index 9c31643..3c25a91 100644 --- a/traffic-masking/SUMMARY.md +++ b/traffic-masking/SUMMARY.md @@ -6,6 +6,9 @@ The project generates an authenticated UDP cover stream. It does not implement the encrypted transport that must multiplex cover bytes with user traffic. Raw UDP output is separately observable and unencrypted. +The runtime has no third-party Python dependencies. `.version` is the canonical +release version; container builds reject a different OCI version argument. + No reference dataset currently establishes that the generated aggregate is statistically indistinguishable from legitimate traffic. diff --git a/traffic-masking/test_cli.py b/traffic-masking/test_cli.py index 0867692..e332f99 100644 --- a/traffic-masking/test_cli.py +++ b/traffic-masking/test_cli.py @@ -3,10 +3,11 @@ """Input validation: bad config raises ValueError; bad CLI exits with code 2.""" -import subprocess -import sys import os +import re import shutil +import subprocess +import sys import pytest @@ -235,3 +236,13 @@ def test_systemd_units_verify_when_analyzer_is_available(): timeout=10, ) assert result.returncode == 0, result.stderr + + +def test_release_version_is_canonical_for_container_metadata(): + version = (BASE_DIR / ".version").read_text().strip() + dockerfile = (BASE_DIR / "Dockerfile").read_text() + + assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) + assert "ARG VERSION" in dockerfile + assert 'org.opencontainers.image.version="${VERSION}"' in dockerfile + assert 'test "${VERSION}" = "$(cat /app/.version)"' in dockerfile