Skip to content

Commit 248418a

Browse files
committed
feat(harvesters): add NetworkHarvester for latency entropy
- Measures round-trip time to Cloudflare, Google, etc. - Network congestion provides natural randomness - Graceful fallback when offline
1 parent 1adefe5 commit 248418a

1 file changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
# =============================================================================
2+
# TrueEntropy - Network Harvester
3+
# =============================================================================
4+
#
5+
# This harvester collects entropy from network latency - the time it takes
6+
# to communicate with remote servers.
7+
#
8+
# Why Network Latency is Random:
9+
# - Physical distance adds baseline delay
10+
# - Network congestion varies constantly
11+
# - Routing changes dynamically
12+
# - Server load fluctuates
13+
# - Packet collisions and retransmissions
14+
#
15+
# Collection Method:
16+
# 1. Send HEAD requests to multiple reliable servers
17+
# 2. Measure round-trip time with high precision
18+
# 3. The variations in timing are our entropy
19+
#
20+
# Entropy Estimate:
21+
# - Conservative: ~8 bits per successful request
22+
# - Latency varies by milliseconds, with microsecond precision
23+
#
24+
# =============================================================================
25+
26+
"""
27+
Network latency-based entropy harvester.
28+
29+
Collects entropy from the timing variations in network requests
30+
to multiple servers.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import struct
36+
import time
37+
from typing import List, Tuple
38+
39+
from trueentropy.harvesters.base import BaseHarvester, HarvestResult
40+
41+
42+
class NetworkHarvester(BaseHarvester):
43+
"""
44+
Harvests entropy from network latency measurements.
45+
46+
This harvester makes lightweight HTTP HEAD requests to reliable
47+
servers and measures the round-trip time. The variations come from:
48+
49+
- Network congestion
50+
- Routing changes
51+
- Server load
52+
- Physical infrastructure conditions
53+
54+
Attributes:
55+
targets: List of URLs to measure latency against
56+
timeout: Request timeout in seconds
57+
58+
Example:
59+
>>> harvester = NetworkHarvester()
60+
>>> result = harvester.collect()
61+
>>> print(f"Collected from network: {result.entropy_bits} bits")
62+
"""
63+
64+
# -------------------------------------------------------------------------
65+
# Default Target Servers
66+
# -------------------------------------------------------------------------
67+
68+
# We use major, reliable services that have high uptime
69+
# These are chosen for:
70+
# - Global distribution (geographic diversity)
71+
# - High reliability (always available)
72+
# - Fast response times (low baseline latency)
73+
74+
DEFAULT_TARGETS: List[str] = [
75+
"https://1.1.1.1", # Cloudflare DNS
76+
"https://8.8.8.8", # Google DNS
77+
"https://www.google.com", # Google
78+
"https://www.cloudflare.com", # Cloudflare
79+
"https://www.microsoft.com", # Microsoft
80+
]
81+
82+
# -------------------------------------------------------------------------
83+
# Initialization
84+
# -------------------------------------------------------------------------
85+
86+
def __init__(
87+
self,
88+
targets: List[str] | None = None,
89+
timeout: float = 2.0
90+
) -> None:
91+
"""
92+
Initialize the network harvester.
93+
94+
Args:
95+
targets: List of URLs to measure latency against.
96+
If None, uses DEFAULT_TARGETS.
97+
timeout: Request timeout in seconds (default: 2.0)
98+
"""
99+
self._targets = targets or self.DEFAULT_TARGETS.copy()
100+
self._timeout = timeout
101+
102+
# -------------------------------------------------------------------------
103+
# BaseHarvester Implementation
104+
# -------------------------------------------------------------------------
105+
106+
@property
107+
def name(self) -> str:
108+
"""Return harvester name."""
109+
return "network"
110+
111+
def collect(self) -> HarvestResult:
112+
"""
113+
Collect entropy from network latency.
114+
115+
Process:
116+
1. Make HEAD requests to each target server
117+
2. Measure round-trip time for each request
118+
3. Pack timing data into bytes
119+
4. Return results with entropy estimate
120+
121+
Note: Failed requests are silently skipped. This harvester
122+
will still succeed if at least one request completes.
123+
124+
Returns:
125+
HarvestResult containing network timing entropy
126+
"""
127+
# Attempt import of requests library
128+
try:
129+
import requests
130+
except ImportError:
131+
return HarvestResult(
132+
data=b"",
133+
entropy_bits=0,
134+
source=self.name,
135+
success=False,
136+
error="requests library not available"
137+
)
138+
139+
# Collect latency measurements
140+
measurements = self._measure_latencies(requests)
141+
142+
if not measurements:
143+
return HarvestResult(
144+
data=b"",
145+
entropy_bits=0,
146+
source=self.name,
147+
success=False,
148+
error="No network targets reachable"
149+
)
150+
151+
# Convert to bytes
152+
data = self._measurements_to_bytes(measurements)
153+
154+
# Estimate entropy: ~8 bits per successful measurement
155+
# Network latency varies by milliseconds at microsecond precision
156+
entropy_bits = len(measurements) * 8
157+
158+
return HarvestResult(
159+
data=data,
160+
entropy_bits=entropy_bits,
161+
source=self.name,
162+
success=True
163+
)
164+
165+
# -------------------------------------------------------------------------
166+
# Private Methods
167+
# -------------------------------------------------------------------------
168+
169+
def _measure_latencies(self, requests_module: object) -> List[Tuple[str, int]]:
170+
"""
171+
Measure latency to each target server.
172+
173+
Args:
174+
requests_module: The imported requests module
175+
176+
Returns:
177+
List of (target, latency_ns) tuples for successful requests
178+
"""
179+
import requests # Type ignore for the module passed in
180+
181+
measurements: List[Tuple[str, int]] = []
182+
183+
for target in self._targets:
184+
try:
185+
# Record start time with nanosecond precision
186+
start = time.perf_counter_ns()
187+
188+
# Make a lightweight HEAD request
189+
# HEAD is faster than GET because it doesn't download the body
190+
requests.head(
191+
target,
192+
timeout=self._timeout,
193+
allow_redirects=False
194+
)
195+
196+
# Record end time
197+
end = time.perf_counter_ns()
198+
199+
# Store the latency
200+
latency_ns = end - start
201+
measurements.append((target, latency_ns))
202+
203+
except requests.RequestException:
204+
# Skip failed requests silently
205+
# This is expected for unreachable networks
206+
pass
207+
except Exception:
208+
# Skip any other errors
209+
pass
210+
211+
return measurements
212+
213+
def _measurements_to_bytes(
214+
self,
215+
measurements: List[Tuple[str, int]]
216+
) -> bytes:
217+
"""
218+
Convert latency measurements to bytes.
219+
220+
We include both the latency values and a hash of the target URLs
221+
to add diversity to the entropy.
222+
223+
Args:
224+
measurements: List of (target, latency_ns) tuples
225+
226+
Returns:
227+
Bytes representation of the measurements
228+
"""
229+
import hashlib
230+
231+
# Pack latency values as 64-bit integers
232+
latencies = [m[1] for m in measurements]
233+
latency_bytes = struct.pack(f"!{len(latencies)}Q", *latencies)
234+
235+
# Add hash of target URLs for additional entropy
236+
# This includes which servers responded (order matters)
237+
targets_str = "|".join(m[0] for m in measurements)
238+
target_hash = hashlib.sha256(targets_str.encode()).digest()[:8]
239+
240+
return latency_bytes + target_hash
241+
242+
# -------------------------------------------------------------------------
243+
# Configuration Properties
244+
# -------------------------------------------------------------------------
245+
246+
@property
247+
def targets(self) -> List[str]:
248+
"""Get the list of target URLs."""
249+
return self._targets.copy()
250+
251+
@targets.setter
252+
def targets(self, value: List[str]) -> None:
253+
"""Set the list of target URLs."""
254+
if not value:
255+
raise ValueError("targets must not be empty")
256+
self._targets = value.copy()
257+
258+
@property
259+
def timeout(self) -> float:
260+
"""Get the request timeout in seconds."""
261+
return self._timeout
262+
263+
@timeout.setter
264+
def timeout(self, value: float) -> None:
265+
"""Set the request timeout in seconds."""
266+
if value <= 0:
267+
raise ValueError("timeout must be positive")
268+
self._timeout = value

0 commit comments

Comments
 (0)