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 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