roughly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roughly/__init__.py +1 -0
- roughly/cli.py +336 -0
- roughly/client.py +324 -0
- roughly/ecosystem.py +287 -0
- roughly/errors.py +38 -0
- roughly/models.py +416 -0
- roughly/py.typed +0 -0
- roughly/server.py +634 -0
- roughly/shared.py +211 -0
- roughly/tags.py +25 -0
- roughly-0.1.0.dist-info/METADATA +228 -0
- roughly-0.1.0.dist-info/RECORD +15 -0
- roughly-0.1.0.dist-info/WHEEL +4 -0
- roughly-0.1.0.dist-info/entry_points.txt +3 -0
- roughly-0.1.0.dist-info/licenses/LICENSE +21 -0
roughly/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Roughtime protocol implementation in Python."""
|
roughly/cli.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
import roughly.client
|
|
13
|
+
import roughly.ecosystem
|
|
14
|
+
import roughly.errors
|
|
15
|
+
import roughly.server
|
|
16
|
+
|
|
17
|
+
# ruff: noqa: FBT001 FBT002 PLR0913
|
|
18
|
+
|
|
19
|
+
REASON_EXPLANATIONS: dict[roughly.errors.RoughtimeErrorReason, str] = {
|
|
20
|
+
"key-age": "The delegated signing key isn't valid at the current time.",
|
|
21
|
+
"merkle": (
|
|
22
|
+
"The server signed timestamps for multiple requests at once."
|
|
23
|
+
" This response could not be proven to be part of the signed batch."
|
|
24
|
+
),
|
|
25
|
+
"signature-response": "The server's response signature could not be verified.",
|
|
26
|
+
"signature-certificate": "The server's delegation certificate signature could not be verified.",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@click.option(
|
|
31
|
+
"--verbose",
|
|
32
|
+
"-v",
|
|
33
|
+
is_flag=True,
|
|
34
|
+
help="Enable logging",
|
|
35
|
+
)
|
|
36
|
+
@click.group()
|
|
37
|
+
def cli(verbose: bool) -> None:
|
|
38
|
+
"""roughly: A Roughtime client."""
|
|
39
|
+
if verbose:
|
|
40
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _query(
|
|
44
|
+
host: str, port: int, public_key: bytes, *, timeout: float
|
|
45
|
+
) -> roughly.client.VerifiableResponse:
|
|
46
|
+
async with asyncio.timeout(timeout):
|
|
47
|
+
return await roughly.client.send_request(host, port, public_key)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _very_dangerously_query(
|
|
51
|
+
host: str, port: int, public_key: bytes | None, *, timeout: float
|
|
52
|
+
) -> roughly.client.VerifiableResponse:
|
|
53
|
+
async with asyncio.timeout(timeout):
|
|
54
|
+
return await roughly.client.very_dangerously_send_request_and_do_not_verify(
|
|
55
|
+
host,
|
|
56
|
+
port,
|
|
57
|
+
public_key,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cli.command()
|
|
62
|
+
@click.argument("host")
|
|
63
|
+
@click.argument("port", type=int, default=2002)
|
|
64
|
+
@click.argument("public-key", required=False)
|
|
65
|
+
@click.option("--terse", is_flag=True, help="Only output the time")
|
|
66
|
+
@click.option("--timeout", type=float, default=5.0, help="Request timeout in seconds")
|
|
67
|
+
@click.option(
|
|
68
|
+
"--very-dangerously-disable-verification",
|
|
69
|
+
is_flag=True,
|
|
70
|
+
help="Disable response verification. Only use this if you ABSOLUTELY know what you're doing!",
|
|
71
|
+
)
|
|
72
|
+
def query(
|
|
73
|
+
host: str,
|
|
74
|
+
port: int,
|
|
75
|
+
public_key: str | None,
|
|
76
|
+
terse: bool,
|
|
77
|
+
timeout: float,
|
|
78
|
+
very_dangerously_disable_verification: bool,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Query a Roughtime server for the current time."""
|
|
81
|
+
query_function = _query
|
|
82
|
+
if very_dangerously_disable_verification:
|
|
83
|
+
query_function = _very_dangerously_query
|
|
84
|
+
elif public_key is None:
|
|
85
|
+
click.echo(
|
|
86
|
+
"Public key is required unless --very-dangerously-disable-verification is set",
|
|
87
|
+
err=True,
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
# The cast here is needed, but safe, because of the check above.
|
|
93
|
+
key_bytes = None
|
|
94
|
+
if public_key is not None:
|
|
95
|
+
key_bytes = base64.b64decode(public_key)
|
|
96
|
+
response = asyncio.run(query_function(host, port, cast(Any, key_bytes), timeout=timeout))
|
|
97
|
+
except TimeoutError:
|
|
98
|
+
click.echo("Request timed out", err=True)
|
|
99
|
+
return
|
|
100
|
+
except roughly.errors.VerificationError as e:
|
|
101
|
+
traceback.print_exc()
|
|
102
|
+
click.echo()
|
|
103
|
+
click.echo("Response received but rejected during verification", err=True)
|
|
104
|
+
explanation = REASON_EXPLANATIONS.get(e.reason)
|
|
105
|
+
if explanation:
|
|
106
|
+
click.echo(f"{e.reason}: {explanation}", err=True)
|
|
107
|
+
else:
|
|
108
|
+
click.echo(f"Reason: {e.reason}", err=True)
|
|
109
|
+
return
|
|
110
|
+
except roughly.errors.RoughtimeError:
|
|
111
|
+
traceback.print_exc()
|
|
112
|
+
click.echo()
|
|
113
|
+
click.echo("A Roughtime protocol error occured while querying the server", err=True)
|
|
114
|
+
click.echo("If you believe this is a bug, please file an issue", err=True)
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
unix_time = response.signed_response.midpoint
|
|
118
|
+
radius = response.signed_response.radius
|
|
119
|
+
if terse:
|
|
120
|
+
click.echo(unix_time)
|
|
121
|
+
else:
|
|
122
|
+
click.echo(f"Current time: {unix_time} ± {radius} seconds")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@cli.group()
|
|
126
|
+
def ecosystem() -> None:
|
|
127
|
+
"""Commands for working with Roughtime ecosystems."""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def _ecosystem_state(ecosystem_path: Path) -> None:
|
|
131
|
+
ecosystem = roughly.ecosystem.load_ecosystem(ecosystem_path)
|
|
132
|
+
selected_servers = await roughly.ecosystem.pick_servers(ecosystem)
|
|
133
|
+
|
|
134
|
+
click.echo(
|
|
135
|
+
f"Out of {len(ecosystem)} servers, {len(selected_servers)} yielded proper responses."
|
|
136
|
+
)
|
|
137
|
+
failed_to_select = {server.name for server in ecosystem} - {
|
|
138
|
+
server.name for server in selected_servers
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if failed_to_select:
|
|
142
|
+
click.echo("Failed to select the following servers:")
|
|
143
|
+
for server in failed_to_select:
|
|
144
|
+
click.echo(f"- {server}")
|
|
145
|
+
|
|
146
|
+
click.echo("\nAvailable servers:")
|
|
147
|
+
for server in selected_servers:
|
|
148
|
+
click.echo(f"- {server.name} ({server.version:#x})")
|
|
149
|
+
|
|
150
|
+
tasks: list[
|
|
151
|
+
asyncio.Task[tuple[roughly.ecosystem.Server, roughly.client.VerifiableResponse | None]]
|
|
152
|
+
] = []
|
|
153
|
+
|
|
154
|
+
for server in selected_servers:
|
|
155
|
+
task = asyncio.create_task(
|
|
156
|
+
roughly.ecosystem._query_server( # pyright: ignore[reportPrivateUsage]
|
|
157
|
+
server,
|
|
158
|
+
timeout=1.0,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
tasks.append(task)
|
|
162
|
+
|
|
163
|
+
results = await asyncio.gather(*tasks)
|
|
164
|
+
|
|
165
|
+
current_time = time.time()
|
|
166
|
+
click.echo(f"\nAt {current_time:.0f} (machine time) received responses from:")
|
|
167
|
+
for server, response in results:
|
|
168
|
+
if response is not None:
|
|
169
|
+
server_time = response.signed_response.midpoint
|
|
170
|
+
radius = response.signed_response.radius
|
|
171
|
+
click.echo(f"- {server.name}: time={server_time} ± {radius} seconds")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@ecosystem.command()
|
|
175
|
+
@click.argument(
|
|
176
|
+
"ecosystem-path",
|
|
177
|
+
type=click.Path(exists=True, path_type=Path),
|
|
178
|
+
default=Path("ecosystem.json"),
|
|
179
|
+
)
|
|
180
|
+
def state(ecosystem_path: Path) -> None:
|
|
181
|
+
"""Evaluate the state of a Roughtime ecosystem."""
|
|
182
|
+
asyncio.run(_ecosystem_state(ecosystem_path))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def _malfeasance_test(
|
|
186
|
+
ecosystem_path: Path,
|
|
187
|
+
always_write: bool = False,
|
|
188
|
+
report_location: Path | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
report_location = report_location or Path("malfeasance_report.json")
|
|
191
|
+
ecosystem = roughly.ecosystem.load_ecosystem(ecosystem_path)
|
|
192
|
+
selected_servers = await roughly.ecosystem.pick_servers(ecosystem)
|
|
193
|
+
click.echo("Selected servers for malfeasance testing:")
|
|
194
|
+
for server in selected_servers:
|
|
195
|
+
click.echo(f"- {server.name}")
|
|
196
|
+
responses = await roughly.ecosystem.query_servers(selected_servers)
|
|
197
|
+
report = roughly.ecosystem.malfeasance_report(responses, selected_servers)
|
|
198
|
+
|
|
199
|
+
if had_malfeasance := roughly.ecosystem.confirm_malfeasance(report):
|
|
200
|
+
click.echo(f"Malfeasance detected. Writing report to '{report_location}'.")
|
|
201
|
+
with report_location.open("w", encoding="utf-8") as f:
|
|
202
|
+
json.dump(report, f, indent=2)
|
|
203
|
+
else:
|
|
204
|
+
click.echo("No malfeasance detected.")
|
|
205
|
+
|
|
206
|
+
if not had_malfeasance and always_write:
|
|
207
|
+
with report_location.open("w", encoding="utf-8") as f:
|
|
208
|
+
json.dump(report, f, indent=2)
|
|
209
|
+
click.echo(f"Report saved to '{report_location}' (no malfeasance detected).")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@ecosystem.command()
|
|
213
|
+
@click.option(
|
|
214
|
+
"--always-write",
|
|
215
|
+
is_flag=True,
|
|
216
|
+
help="Always write a malfeasance report, even if no malfeasance is detected",
|
|
217
|
+
)
|
|
218
|
+
@click.option(
|
|
219
|
+
"--report-location",
|
|
220
|
+
type=click.Path(path_type=Path),
|
|
221
|
+
help="Location to save the malfeasance report",
|
|
222
|
+
)
|
|
223
|
+
@click.option(
|
|
224
|
+
"--ecosystem-path",
|
|
225
|
+
type=click.Path(exists=True, path_type=Path),
|
|
226
|
+
default=Path("ecosystem.json"),
|
|
227
|
+
help="Path to the ecosystem JSON file",
|
|
228
|
+
)
|
|
229
|
+
def malfeasance(always_write: bool, report_location: Path | None, ecosystem_path: Path) -> None:
|
|
230
|
+
"""Run a malfeasance test on the Roughtime ecosystem."""
|
|
231
|
+
asyncio.run(
|
|
232
|
+
_malfeasance_test(
|
|
233
|
+
always_write=always_write,
|
|
234
|
+
report_location=report_location,
|
|
235
|
+
ecosystem_path=ecosystem_path,
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@cli.group()
|
|
241
|
+
def server() -> None:
|
|
242
|
+
"""Commands for running a Roughtime server."""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@server.command(name="run")
|
|
246
|
+
@click.option("--host", default="0.0.0.0", help="Host to bind to") # noqa: S104
|
|
247
|
+
@click.option("--port", "-p", default=2002, type=int, help="Port to bind to")
|
|
248
|
+
@click.option(
|
|
249
|
+
"--private-key",
|
|
250
|
+
type=str,
|
|
251
|
+
help="Base64-encoded 32-byte Ed25519 private key. If not provided, generates a new key.",
|
|
252
|
+
envvar="ROUGHLY_PRIVATE_KEY",
|
|
253
|
+
)
|
|
254
|
+
@click.option(
|
|
255
|
+
"--radius",
|
|
256
|
+
default=3,
|
|
257
|
+
type=int,
|
|
258
|
+
help="Uncertainty radius in seconds",
|
|
259
|
+
)
|
|
260
|
+
@click.option(
|
|
261
|
+
"--validity-seconds",
|
|
262
|
+
default=None,
|
|
263
|
+
type=int,
|
|
264
|
+
help="Validity period for the delegated key in seconds. "
|
|
265
|
+
"If not set, defaults to 3600 seconds (1 hour).",
|
|
266
|
+
)
|
|
267
|
+
@click.option(
|
|
268
|
+
"--no-grease",
|
|
269
|
+
is_flag=True,
|
|
270
|
+
help="Disable response greasing",
|
|
271
|
+
envvar="ROUGHLY_NO_GREASE",
|
|
272
|
+
)
|
|
273
|
+
@click.option(
|
|
274
|
+
"--grease-probability",
|
|
275
|
+
default=None,
|
|
276
|
+
type=float,
|
|
277
|
+
help="Probability of greasing responses (between 0.0 and 1.0). "
|
|
278
|
+
f"If not set, defaults to {roughly.server.GREASE_PROBABILITY} "
|
|
279
|
+
f"({roughly.server.GREASE_PROBABILITY * 100:.2f}%).",
|
|
280
|
+
envvar="ROUGHLY_GREASE_PROBABILITY",
|
|
281
|
+
)
|
|
282
|
+
def server_run(
|
|
283
|
+
host: str,
|
|
284
|
+
port: int,
|
|
285
|
+
private_key: str | None,
|
|
286
|
+
radius: int,
|
|
287
|
+
validity_seconds: int | None,
|
|
288
|
+
no_grease: bool,
|
|
289
|
+
grease_probability: float | None,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Run a Roughtime server."""
|
|
292
|
+
key_bytes = base64.b64decode(private_key) if private_key else None
|
|
293
|
+
|
|
294
|
+
config = roughly.server.Server.create(
|
|
295
|
+
key_bytes,
|
|
296
|
+
validity_seconds=validity_seconds,
|
|
297
|
+
radius=radius,
|
|
298
|
+
grease=not no_grease,
|
|
299
|
+
grease_probability=grease_probability,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
pub_bytes = roughly.server.public_key_bytes(config.long_term_key)
|
|
303
|
+
public_key_b64 = base64.b64encode(pub_bytes).decode()
|
|
304
|
+
click.echo(f"Server public key (base64): {public_key_b64}")
|
|
305
|
+
click.echo(f"Starting Roughtime server on {host}:{port}")
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
asyncio.run(roughly.server.serve(config, host, port))
|
|
309
|
+
except KeyboardInterrupt:
|
|
310
|
+
click.echo("\nServer stopped.")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@server.command(name="keygen")
|
|
314
|
+
def server_keygen() -> None:
|
|
315
|
+
"""Generate a new Ed25519 key pair for the server."""
|
|
316
|
+
key = roughly.server.generate_key()
|
|
317
|
+
private_key_bytes = key.private_bytes_raw()
|
|
318
|
+
pub_key_bytes = roughly.server.public_key_bytes(key)
|
|
319
|
+
output_data = f"ROUGHLY_PRIVATE_KEY={base64.b64encode(private_key_bytes).decode()}"
|
|
320
|
+
|
|
321
|
+
path = Path(".env")
|
|
322
|
+
if path.exists() and not click.confirm(
|
|
323
|
+
"'.env' file already exists. Overwrite?", default=False, show_default=True
|
|
324
|
+
):
|
|
325
|
+
click.echo("Aborting key generation.")
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
with path.open("w", encoding="utf-8") as f:
|
|
329
|
+
f.write(output_data + "\n")
|
|
330
|
+
|
|
331
|
+
click.echo(f"Public key (base64): {base64.b64encode(pub_key_bytes).decode()}")
|
|
332
|
+
click.echo(f"Private key saved to '{path}'. Keep it secret!")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
if __name__ == "__main__":
|
|
336
|
+
cli()
|
roughly/client.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import struct
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
10
|
+
|
|
11
|
+
import cryptography.exceptions
|
|
12
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
13
|
+
|
|
14
|
+
from roughly import tags
|
|
15
|
+
from roughly.errors import PacketError, RoughtimeError, VerificationError
|
|
16
|
+
from roughly.models import (
|
|
17
|
+
Message,
|
|
18
|
+
Packet,
|
|
19
|
+
Response,
|
|
20
|
+
Tag,
|
|
21
|
+
)
|
|
22
|
+
from roughly.shared import (
|
|
23
|
+
GOOGLE_ROUGHTIME_SENTINEL,
|
|
24
|
+
RESPONSE_CONTEXT_STRING,
|
|
25
|
+
VERSIONS_SUPPORTED,
|
|
26
|
+
ProtocolProfile,
|
|
27
|
+
find_by_predicate,
|
|
28
|
+
partial_sha512,
|
|
29
|
+
pop_by_tag,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Iterable
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
T = TypeVar("T")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class QueueDatagramProtocol(asyncio.DatagramProtocol):
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.transport: asyncio.DatagramTransport | None = None
|
|
45
|
+
self.queue: asyncio.Queue[tuple[bytes, tuple[str, int]] | Exception] = asyncio.Queue()
|
|
46
|
+
|
|
47
|
+
def connection_made(self, transport: asyncio.DatagramTransport) -> None:
|
|
48
|
+
self.transport = transport
|
|
49
|
+
|
|
50
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
51
|
+
self.queue.put_nowait((data, addr))
|
|
52
|
+
|
|
53
|
+
def error_received(self, exc: Exception) -> None:
|
|
54
|
+
self.queue.put_nowait(exc)
|
|
55
|
+
|
|
56
|
+
def connection_lost(self, exc: Exception | None) -> None:
|
|
57
|
+
if exc:
|
|
58
|
+
self.queue.put_nowait(exc)
|
|
59
|
+
self.queue.put_nowait(RoughtimeError("Connection closed unexpectedly"))
|
|
60
|
+
|
|
61
|
+
async def recv(self) -> bytes:
|
|
62
|
+
item = await self.queue.get()
|
|
63
|
+
if isinstance(item, Exception):
|
|
64
|
+
raise item
|
|
65
|
+
return item[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def open_udp_socket(host: str, port: int): # noqa: ANN201
|
|
69
|
+
loop = asyncio.get_running_loop()
|
|
70
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
71
|
+
QueueDatagramProtocol,
|
|
72
|
+
remote_addr=(host, port),
|
|
73
|
+
)
|
|
74
|
+
return transport, protocol
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def send_request(
|
|
78
|
+
host: str,
|
|
79
|
+
port: int,
|
|
80
|
+
public_key: bytes,
|
|
81
|
+
*,
|
|
82
|
+
versions: Iterable[int] | None = None,
|
|
83
|
+
nonce: bytes | None = None,
|
|
84
|
+
) -> VerifiableResponse:
|
|
85
|
+
response = await very_dangerously_send_request_and_do_not_verify(
|
|
86
|
+
host,
|
|
87
|
+
port,
|
|
88
|
+
public_key,
|
|
89
|
+
versions=versions,
|
|
90
|
+
nonce=nonce,
|
|
91
|
+
)
|
|
92
|
+
response.verify(public_key)
|
|
93
|
+
logger.debug("Verified response from %s:%d", host, port)
|
|
94
|
+
return response
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def very_dangerously_send_request_and_do_not_verify(
|
|
98
|
+
host: str,
|
|
99
|
+
port: int,
|
|
100
|
+
public_key: bytes | None = None,
|
|
101
|
+
*,
|
|
102
|
+
versions: Iterable[int] | None = None,
|
|
103
|
+
nonce: bytes | None = None,
|
|
104
|
+
) -> VerifiableResponse:
|
|
105
|
+
"""As should be clear from the function name, this function sends a Roughtime request
|
|
106
|
+
but does NOT verify the response in any way. This is dangerous and should only be used
|
|
107
|
+
if you REALLY know what you're doing.""" # noqa: D205 D209
|
|
108
|
+
logger.debug(
|
|
109
|
+
"Sending request to %s:%d with versions=%s",
|
|
110
|
+
host,
|
|
111
|
+
port,
|
|
112
|
+
", ".join(f"{v:#x}" for v in versions) if versions else "default",
|
|
113
|
+
)
|
|
114
|
+
transport, protocol = await open_udp_socket(host, port)
|
|
115
|
+
logger.debug("Opened UDP socket to %s:%d", host, port)
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
p = build_request(versions=versions, public_key=public_key, nonce=nonce)
|
|
119
|
+
payload = p.dump()
|
|
120
|
+
transport.sendto(payload)
|
|
121
|
+
logger.debug("Sent request to %s:%d", host, port)
|
|
122
|
+
|
|
123
|
+
data = await protocol.recv()
|
|
124
|
+
logger.debug("Received %d bytes from %s:%d", len(data), host, port)
|
|
125
|
+
response = VerifiableResponse.from_packet(raw=data, request=payload)
|
|
126
|
+
logger.debug("Parsed (unverified) response from %s:%d", host, port)
|
|
127
|
+
finally:
|
|
128
|
+
transport.close()
|
|
129
|
+
|
|
130
|
+
return response
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build_request(
|
|
134
|
+
versions: Iterable[int] | None = None,
|
|
135
|
+
public_key: bytes | None = None,
|
|
136
|
+
nonce: bytes | None = None,
|
|
137
|
+
) -> Packet:
|
|
138
|
+
"""Build a spec-compliant request padded to 1024 bytes (UDP)."""
|
|
139
|
+
if versions is None:
|
|
140
|
+
versions = VERSIONS_SUPPORTED
|
|
141
|
+
|
|
142
|
+
ver = b"".join(struct.pack("<I", v) for v in versions) # VER: uint32 list
|
|
143
|
+
|
|
144
|
+
if nonce is None:
|
|
145
|
+
nonce = os.urandom(32)
|
|
146
|
+
|
|
147
|
+
tag_list: list[Tag] = [
|
|
148
|
+
Tag(tag=tags.VER, value=ver),
|
|
149
|
+
Tag(tag=tags.NONC, value=nonce),
|
|
150
|
+
Tag(tag=tags.TYPE, value=struct.pack("<I", tags.TYPE_REQUEST)),
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
if public_key is not None:
|
|
154
|
+
tag_list.append(Tag(tag=tags.SRV, value=partial_sha512(b"\xff" + public_key)))
|
|
155
|
+
|
|
156
|
+
message = Message(tags=tag_list)
|
|
157
|
+
message.prepare()
|
|
158
|
+
return Packet(message=message)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass
|
|
162
|
+
class VerifiableResponse(Response):
|
|
163
|
+
"""Client-side response with verification context."""
|
|
164
|
+
|
|
165
|
+
raw: bytes
|
|
166
|
+
"""The raw bytes of the Roughtime response packet"""
|
|
167
|
+
|
|
168
|
+
request: bytes
|
|
169
|
+
"""The raw bytes of Roughtime packet that triggered this response"""
|
|
170
|
+
|
|
171
|
+
packet: Packet
|
|
172
|
+
"""The full Roughtime response packet"""
|
|
173
|
+
|
|
174
|
+
dele_raw: bytes
|
|
175
|
+
"""The raw DELE tag bytes for signature verification"""
|
|
176
|
+
|
|
177
|
+
srep_raw: bytes
|
|
178
|
+
"""The raw SREP tag bytes for signature verification"""
|
|
179
|
+
|
|
180
|
+
_profile: ProtocolProfile
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def version(self) -> int:
|
|
184
|
+
"""The version of the response."""
|
|
185
|
+
if not self.signed_response.version:
|
|
186
|
+
result = find_by_predicate(self.packet.message.tags, lambda t: t.tag == tags.VER)
|
|
187
|
+
if result is None:
|
|
188
|
+
raise PacketError("No VER tag found in response packet")
|
|
189
|
+
(version,) = struct.unpack("<I", self.packet.message.tags[result].value[:4])
|
|
190
|
+
return version
|
|
191
|
+
|
|
192
|
+
return self.signed_response.version
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_packet(cls, *, raw: bytes, request: bytes) -> VerifiableResponse:
|
|
196
|
+
p = Packet.from_bytes(raw)
|
|
197
|
+
|
|
198
|
+
ver_result = find_by_predicate(p.message.tags, lambda t: t.tag == tags.VER)
|
|
199
|
+
if ver_result is not None:
|
|
200
|
+
(wire_ver,) = struct.unpack("<I", p.message.tags[ver_result].value)
|
|
201
|
+
else:
|
|
202
|
+
wire_ver = GOOGLE_ROUGHTIME_SENTINEL
|
|
203
|
+
wire_profile = ProtocolProfile.from_version(wire_ver)
|
|
204
|
+
|
|
205
|
+
response, dele_raw, srep_raw = Response.from_message(p.message, profile=wire_profile)
|
|
206
|
+
|
|
207
|
+
verifiable = cls(
|
|
208
|
+
signature=response.signature,
|
|
209
|
+
nonce=response.nonce,
|
|
210
|
+
type=response.type,
|
|
211
|
+
path=response.path,
|
|
212
|
+
signed_response=response.signed_response,
|
|
213
|
+
certificate=response.certificate,
|
|
214
|
+
index=response.index,
|
|
215
|
+
raw=raw,
|
|
216
|
+
request=request,
|
|
217
|
+
packet=p,
|
|
218
|
+
dele_raw=dele_raw,
|
|
219
|
+
srep_raw=srep_raw,
|
|
220
|
+
_profile=wire_profile,
|
|
221
|
+
)
|
|
222
|
+
verifiable._profile = ProtocolProfile.from_version(verifiable.version)
|
|
223
|
+
|
|
224
|
+
if wire_profile.type_tag_required and response.type is None:
|
|
225
|
+
raise PacketError("TYPE tag missing in response")
|
|
226
|
+
|
|
227
|
+
request_message = Packet.from_bytes(request).message
|
|
228
|
+
vers = pop_by_tag(request_message.tags, tags.VER)
|
|
229
|
+
versions = struct.unpack(f"<{len(vers.value) // 4}I", vers.value)
|
|
230
|
+
if verifiable.version not in versions:
|
|
231
|
+
raise PacketError(
|
|
232
|
+
f"Response version {verifiable.version:#x} not in request VER list: "
|
|
233
|
+
+ ", ".join(f"{v:#x}" for v in versions)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
nonc = pop_by_tag(request_message.tags, tags.NONC)
|
|
237
|
+
if verifiable.nonce != nonc.value:
|
|
238
|
+
raise PacketError("Response NONC does not match request NONC")
|
|
239
|
+
|
|
240
|
+
return verifiable
|
|
241
|
+
|
|
242
|
+
def _verify_merkle(self) -> bool:
|
|
243
|
+
raw = self.request if self._profile.leaf_from_request else self.nonce
|
|
244
|
+
h = self._profile.hasher(b"\x00" + raw)
|
|
245
|
+
|
|
246
|
+
for i, node in enumerate(self.path):
|
|
247
|
+
if (self.index >> i) & 1 == 0:
|
|
248
|
+
h = self._profile.hasher(b"\x01" + h + node)
|
|
249
|
+
else:
|
|
250
|
+
h = self._profile.hasher(b"\x01" + node + h)
|
|
251
|
+
|
|
252
|
+
return h == self.signed_response.root
|
|
253
|
+
|
|
254
|
+
def verify(self, long_term_public_key_bytes: bytes) -> bool: # noqa: C901
|
|
255
|
+
delegation_context_string = self._profile.delegation_context
|
|
256
|
+
|
|
257
|
+
# 5.4. Validity of Response
|
|
258
|
+
|
|
259
|
+
# Structural checks on the signed response (§5.2.5).
|
|
260
|
+
srep = self.signed_response
|
|
261
|
+
|
|
262
|
+
# §5.2.5 L691: RADI MUST NOT be zero.
|
|
263
|
+
if srep.radius == 0:
|
|
264
|
+
raise VerificationError("RADI must not be zero", reason="radius")
|
|
265
|
+
|
|
266
|
+
# §5.2.5 L701-704: VERS structure (skip Google profile, which has no VERS tag).
|
|
267
|
+
if srep.versions:
|
|
268
|
+
if len(srep.versions) > 32: # noqa: PLR2004
|
|
269
|
+
raise VerificationError(
|
|
270
|
+
f"VERS contains {len(srep.versions)} entries (max 32)",
|
|
271
|
+
reason="versions",
|
|
272
|
+
)
|
|
273
|
+
for prev, curr in zip(srep.versions, srep.versions[1:], strict=False):
|
|
274
|
+
if curr <= prev:
|
|
275
|
+
raise VerificationError(
|
|
276
|
+
"VERS must be strictly ascending and unique",
|
|
277
|
+
reason="versions",
|
|
278
|
+
)
|
|
279
|
+
if srep.version not in srep.versions:
|
|
280
|
+
raise VerificationError(
|
|
281
|
+
f"VERS does not contain the response version {srep.version:#x}",
|
|
282
|
+
reason="versions",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# The signature in CERT was made with the long-term key of the server.
|
|
286
|
+
long_term_public_key = ed25519.Ed25519PublicKey.from_public_bytes(
|
|
287
|
+
long_term_public_key_bytes
|
|
288
|
+
)
|
|
289
|
+
try:
|
|
290
|
+
long_term_public_key.verify(
|
|
291
|
+
self.certificate.signature,
|
|
292
|
+
delegation_context_string + self.dele_raw,
|
|
293
|
+
)
|
|
294
|
+
except cryptography.exceptions.InvalidSignature as e:
|
|
295
|
+
raise VerificationError(
|
|
296
|
+
"Certificate signature invalid", reason="signature-certificate"
|
|
297
|
+
) from e
|
|
298
|
+
|
|
299
|
+
# The MIDP timestamp lies in the interval specified by the MINT and MAXT timestamps.
|
|
300
|
+
midp = self.signed_response.midpoint
|
|
301
|
+
if not (
|
|
302
|
+
self.certificate.delegation.min_time <= midp <= self.certificate.delegation.max_time
|
|
303
|
+
):
|
|
304
|
+
raise VerificationError(
|
|
305
|
+
"MIDP timestamp is outside of delegation bounds", reason="key-age"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# The INDX and PATH values prove a hash value derived from the request packet was
|
|
309
|
+
# included in the Merkle tree with value ROOT
|
|
310
|
+
if not self._verify_merkle():
|
|
311
|
+
raise VerificationError("Merkle tree verification failed", reason="merkle")
|
|
312
|
+
|
|
313
|
+
# The signature of SREP in SIG validates with the public key in DELE.
|
|
314
|
+
public_key = ed25519.Ed25519PublicKey.from_public_bytes(
|
|
315
|
+
self.certificate.delegation.public_key
|
|
316
|
+
)
|
|
317
|
+
try:
|
|
318
|
+
public_key.verify(self.signature, RESPONSE_CONTEXT_STRING + self.srep_raw)
|
|
319
|
+
except cryptography.exceptions.InvalidSignature as e:
|
|
320
|
+
raise VerificationError(
|
|
321
|
+
"Response signature invalid", reason="signature-response"
|
|
322
|
+
) from e
|
|
323
|
+
|
|
324
|
+
return True
|