diff --git a/CHANGELOG.md b/CHANGELOG.md index 965767f..4c7ffd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## 0.2.0 (unreleased) +- TLS runs inside the simulation. `create_connection(ssl=...)`, + `create_server(ssl=...)` and `start_tls` on an already-established + connection all work, and so do `asyncio.open_connection` and `start_server` + on top of them. The handshake is the real thing — the standard library's + `SSLProtocol` driving OpenSSL over a pair of memory BIOs, with real + certificate verification, so a hostname the certificate does not cover + raises `ssl.SSLCertVerificationError` — and no file descriptor, no real + socket and no wall-clock second is involved anywhere. Each flight OpenSSL + produces leaves as one ordinary simulated packet and pays the link's seeded + latency, so a client connect costs two round trips; `ssl_handshake_timeout` + and `ssl_shutdown_timeout` are ordinary loop timers, so a handshake a + partition stalls costs sixty virtual seconds and milliseconds of real ones. + aiohttp's `https://`, httpx's `https://` and websockets' `wss://` now run + under simulation, with the evidence in + [docs/compatibility.md](docs/compatibility.md). The caveat, stated plainly: + for a workload that uses TLS the hash promise gains two clauses — same + OpenSSL build, same TLS configuration — because the number of packets a + handshake makes is a property of the engine. Certificates are not among + them, measured: an EC leaf and an RSA leaf record the same hash, since the + trace records how many packets crossed and in what order, never their + bytes. A run that never asks for TLS is unaffected, which pinned reference + digests keep true. - `drain()` can finally block. `loop.net.set_flow_control()` gives stream transports a write buffer that holds every byte written but not yet received by the peer's protocol — still in flight, held by a partition, diff --git a/README.md b/README.md index 76fe4fb..4ca0ae0 100644 --- a/README.md +++ b/README.md @@ -283,8 +283,8 @@ and the campaign results: Code that goes through the event-loop API is supported; code that bypasses it is fenced: threads, raw socket reads and writes, -subprocesses, signals, and loop-level TLS upgrades raise -`SimulationFenceError` rather than silently breaking determinism. +subprocesses and signals raise `SimulationFenceError` rather than +silently breaking determinism. Executor submissions stay inside the line: `run_in_executor` runs the function inline at a seeded scheduling step — no pool, no thread — so `asyncio.to_thread` works, and `call_soon_threadsafe` is `call_soon` @@ -296,6 +296,11 @@ simulated, so a client that connects a socket and hands it to Name resolution stays inside the simulation: `getaddrinfo` resolves sim host names to stable synthetic addresses and raises `socket.gaierror` for anything else — no real DNS, ever. +TLS runs inside the simulation: a real handshake through the standard +library's `SSLProtocol` over a pair of memory BIOs, with real certificate +verification, no descriptor and no wall clock — each flight is one +simulated packet paying the link's latency, and a handshake deadline fires +in virtual time. Write-side flow control is simulated on request: `net.set_flow_control()` makes `drain()` really wait while the peer has not read, so backpressure deadlocks and missing pause/resume handling become findable. It is off by diff --git a/docs/compatibility.md b/docs/compatibility.md index 3849514..1ea7833 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -5,7 +5,8 @@ simulates. This page answers the next question — what happens when a real library runs on top of them — with evidence rather than intent: every row below is the output of a script anyone can re-run. -Recorded **2026-08-01**, against simloop 0.2.0 (unreleased) on Python 3.12. +Recorded **2026-08-04**, against simloop 0.2.0 (unreleased) on Python 3.12 +with OpenSSL 3.5.7. ## What a probe is @@ -23,8 +24,9 @@ network — and reports a single verdict: A verdict is a statement about that one run and nothing more. `works` means those calls, on that version, produced that result; it is not a support claim, and the same library may well fence one call later. The probes drive -happy paths only: no TLS, no retries, no reconnection, no concurrency beyond -what the probe itself starts. +happy paths only: no retries, no reconnection, no concurrency beyond what the +probe itself starts. TLS is a happy path they now drive, with certificates +minted in memory for the sim hostnames the probes use. ## Regenerating the table @@ -46,8 +48,11 @@ carries a date instead. | anyio | 4.14.2 | works: task group, memory object stream (one, two, three), anyio.sleep and move_on_after; virtual clock reached 1.75s | Asyncio backend only; nothing here touches a socket. | | redis (RESP wire protocol) | n/a | works: PING, SET and GET round trips over one connection: ['+PONG', '+OK', '0'] | Hand-rolled RESP over sim streams; no client library, no real server. | | websockets | 17.0.1 | works: handshake, one echoed frame ('HELLO') and close over ws:// | asyncio server and client on two sim hosts, ws:// only. | +| websockets (wss) | 17.0.1 | works: handshake, one echoed frame ('HELLO') and close over wss:// | asyncio server and client on two sim hosts, over wss://. | | aiohttp (client) | 3.14.3 | works: ClientSession GET returned 'hello from the simulation' | ClientSession GET at a sim host answered by a raw stream server. | +| aiohttp (client, https) | 3.14.3 | works: ClientSession GET over https returned 'hello from the simulation' | ClientSession GET over https at a sim host with a minted certificate. | | httpx | 0.28.1 | works: AsyncClient GET returned 'hello from the simulation' | AsyncClient GET at a sim host answered by a raw stream server. | +| httpx (https) | 0.28.1 | works: AsyncClient GET over https returned 'hello from the simulation' | AsyncClient GET over https; the TLS engine is anyio's, not the loop's. | Rows are grouped: the libraries that need nothing but the loop and its streams first, then the client stacks that expect a socket object @@ -104,8 +109,8 @@ on it. A transport with no operating-system socket now answers with a stand-in that reports the peer's synthetic address and port, so the introspection succeeds and the response body comes back. -Both client probes make one request against a responder that sends -`Connection: close`, so neither row says anything about connection reuse. +Every client probe makes one request against a responder that sends +`Connection: close`, so no row says anything about connection reuse. The piece a pool depends on is the descriptor `fileno()` returns: httpcore polls it to decide whether a pooled connection has died, and the simulation backs it with a parked descriptor the transport owns, which @@ -114,32 +119,37 @@ peer's EOF arrives; a reset or a teardown closes it and `fileno()` returns `-1`, which the same poll reads as dead just as well. That contract is pinned by the test suite, not by these rows. -Both client rows are `http://` only, and the two stacks stop differently -on `https://`. aiohttp asks for TLS through `create_connection(ssl=...)`, -which fences: - -``` -simloop does not simulate 'create_connection(ssl=...)'; see docs/supported-api.md for the supported asyncio subset -``` - -httpx reaches no fence. httpcore wraps the byte stream with anyio's -`TLSStream`, which drives an `ssl` memory BIO inside the process and -sends the handshake as ordinary bytes over the simulated connection, so -`loop.start_tls` is never called and nothing stops the attempt. Where it -ends is up to whatever is listening: aimed at the plaintext responder -these probes use, the handshake goes unanswered and the request dies of -httpx's own `ConnectTimeout`. That timeout is a one-off measurement -rather than a row — no probe on this page requests `https://`. +**aiohttp over `https://`** completes the same two-call connect its +`http://` path uses, with `ssl` and `server_hostname` riding alongside +`sock` in the `create_connection` call. What the connector does after the +connect is answered by the two layers together: `sslcontext` and +`ssl_object` come from the TLS layer, `peername` and the `setsockopt` on +the stand-in socket from the simulated transport underneath it. The +certificate is minted for the sim hostname `web` and the client context +trusts that authority and nothing else, so the row says OpenSSL really +verified rather than that verification was turned off. + +**httpx over `https://`** reaches no loop TLS API at all, which is why it +is worth its own row. httpcore wraps the byte stream with anyio's +`TLSStream`, which drives an `ssl` memory BIO inside the process and sends +the handshake as ordinary bytes over the simulated connection. Nothing in +simloop is involved in that handshake; what changed is that the simulation +now has a peer on the other end that speaks TLS back, so the request +completes instead of dying of httpx's own `ConnectTimeout`. + +**websockets over `wss://`** completes a handshake, echoes a frame and +closes with both ends inside the simulation. It is the only row that +drives `create_server(ssl=...)` and `create_connection(ssl=...)` in one +run. ## Not tested - **asyncpg**: reaching its first fence needs a live PostgreSQL server to connect to, which no probe can provide; it is untested rather than fenced-or-not. -- **TLS anywhere**: no probe on this page requests `https://` or `wss://`. - simloop fences `start_tls` and `create_connection(ssl=...)`, but a stack - that runs its handshake in memory reaches neither — it reaches a simulated - network with nothing on it that speaks TLS unless the test puts it there. +- **The rest of TLS**: the three TLS rows drive a server certificate, one + cipher suite and TLS 1.3. Client certificates, a peer restricted to TLS + 1.2, ALPN and h2 negotiation, and session resumption are not probed. - Anything that reaches outside the loop by design — threads, subprocesses, signals, real DNS. Those are fences, listed in [docs/supported-api.md](supported-api.md), not compatibility questions. diff --git a/docs/design.md b/docs/design.md index 9f78761..2741026 100644 --- a/docs/design.md +++ b/docs/design.md @@ -136,9 +136,10 @@ production. Anything that would reach outside the simulation — real threads, signals, subprocesses, raw sockets, `add_reader`/`add_writer`, -TLS, pipes, `sendfile` — raises `SimulationFenceError` naming the exact call, -and optional stdlib kwargs that would smuggle those in (`ssl=`, `sock=`, …) -are rejected the same way. +pipes, `sendfile` — raises `SimulationFenceError` naming the exact call, +and an optional stdlib argument that would smuggle one in is rejected the +same way: whatever is left after the simulated arguments have been taken and +is actually asked for fences rather than being quietly ignored. The tempting alternative was best-effort passthrough: hand `run_in_executor` a real thread pool, keep most libraries importable, appear more compatible. @@ -256,10 +257,37 @@ Decisions inside that model, each doing real work: `datagram_received` belongs to the receiving machine, and `crash` knows exactly which tasks to kill. -What was cut, deliberately: retransmission and congestion modeling, IP -addresses, TLS. Each would deepen the simulation without widening the class -of bugs it can catch; the supported-subset contract beats chasing 100% of -the asyncio surface. +What was cut, deliberately: retransmission and congestion modeling. It would +deepen the simulation without widening the class of bugs it can catch; the +supported-subset contract beats chasing 100% of the asyncio surface. + +## TLS: real bytes, and what that costs the hash + +The handshake is the standard library's `SSLProtocol` over a pair of memory +BIOs — the same machinery uvloop reuses — because a hand-rolled pump would +have to re-derive waiter semantics, handshake and shutdown deadlines, +close-notify handling and flow-control passthrough, and would get one of them +wrong. The transport underneath it gained exactly three things: a buffered +delivery path, a `_force_close` that carries a failure into +`connection_lost`, and the flag `start_tls` insists on before it will touch a +transport at all. + +Its wire bytes are genuinely random and differ every run. That is affordable +because the trace does not hash payloads — only the number and order of +packets and callbacks — and because `SSLProtocol` drains its outgoing BIO +once per flight, so one flight is one write is one simulated packet. A +certificate's size and key type therefore leave the hash alone: an EC leaf +and an RSA leaf record the same one. Anything that changes the flight +structure does not: a different OpenSSL build, session tickets turned off, a +peer that only speaks TLS 1.2. So the promise for a TLS workload is one +clause longer than the promise for everything else, and only for a TLS +workload — a run that never asks for TLS makes no new draw, arms no new timer +and records no new event, which a pinned reference hash keeps true. + +The handshake pays the wire the way everything else does, which is free +realism: a client connect costs two round trips of the configured latency, +and `ssl_handshake_timeout` is an ordinary `call_later`, so a partition that +outlasts it costs sixty virtual seconds and milliseconds of wall clock. ## Explorer and pytest plugin: a thin shell by design diff --git a/docs/supported-api.md b/docs/supported-api.md index b385088..2cb8fe1 100644 --- a/docs/supported-api.md +++ b/docs/supported-api.md @@ -38,6 +38,10 @@ a host belong to an implicit `driver` host. | API | Behavior under simulation | |---|---| | `loop.create_connection` / `create_server`, `asyncio.open_connection` / `start_server` | Real transports and protocols over reliable, ordered in-memory streams; connecting costs one round trip of virtual latency; connecting to a closed port raises `ConnectionRefusedError` | +| `loop.create_connection(ssl=...)` / `create_server(ssl=...)`, `asyncio.open_connection(ssl=...)` / `start_server(ssl=...)` | A real TLS handshake, driven by the standard library's `SSLProtocol` over a pair of memory BIOs. No descriptor and no real socket anywhere: each flight OpenSSL produces leaves as one ordinary simulated packet and pays the link's seeded latency, so a client connect costs two round trips — one for the connection, one for the handshake — which is what a real TCP + TLS 1.3 connect costs. Verification is the real thing: a hostname the certificate does not cover raises `ssl.SSLCertVerificationError`. `ssl=True` means the standard library's default client context, which trusts the system store and therefore rejects a simulation's own certificate; on a server it is a `ValueError` when the listener is created, since there is no default certificate to present. A handshake a client fails is reset and does not fail the run | +| `loop.start_tls` | Upgrades an established simulated connection in place and returns the new transport the protocol should write to — including the server-side case where the stream reader has already buffered the bytes the handshake needs | +| `ssl_handshake_timeout` / `ssl_shutdown_timeout` | Ordinary loop timers, so they fire in virtual time at the standard library's defaults of 60 s and 30 s. A handshake a partition stalls costs sixty virtual seconds and milliseconds of wall clock, and raises `ConnectionAbortedError` | +| `transport.get_extra_info` on a TLS transport | `ssl_object`, `peercert`, `cipher`, `compression` and `sslcontext` come from the TLS layer; `socket`, `peername` and `sockname` fall through to the simulated transport underneath, so the stand-in socket row below still applies | | `loop.create_datagram_endpoint` | Unreliable messaging: per-link drop, duplication, and latency apply per datagram | | `loop.net.set_defaults` / `set_link` | Per-direction latency ranges, drop and duplication probabilities, drawn from a seed-derived stream | | `loop.net.partition` / `heal` | Silent blackhole: datagrams are lost, stream traffic is held and resumes intact after healing; nothing errors — only your own timeouts fire | @@ -73,25 +77,28 @@ so skewing a worker changes nothing the cluster decides. Clock faults reach only code that compares timestamps taken on different machines. Limitations, stated honestly: there is no retransmission or congestion model -— streams are reliable by construction; and addressing is IPv4-only and -entirely synthetic — there are no routes, no netmasks, and no service-name -database. +— streams are reliable by construction; addressing is IPv4-only and entirely +synthetic — there are no routes, no netmasks, and no service-name database; +TLS has no half-close, so `write_eof()` on a TLS transport raises +`NotImplementedError` and `can_write_eof()` is `False`, exactly as on a real +one; and DTLS is not simulated — datagram endpoints take no TLS arguments. ## Fenced Anything that reaches outside the simulation raises `SimulationFenceError`: real threads (`call_soon_threadsafe` from any thread but the loop's own), signal handlers, subprocesses, file-descriptor callbacks (`add_reader` / -`add_writer`), loop-level TLS upgrades (`start_tls`, -`create_connection(ssl=...)`), `sendfile`, pipes, and an eager task start +`add_writer`), `sendfile`, pipes, and an eager task start (`create_task(eager_start=True)`), which would run a task's first step at creation time, before the seeded draw could order it against anything. Executor *submissions* are not in that list — `run_in_executor` runs the function inline, as the table above says — but the pool machinery around them still is: `set_default_executor` and `shutdown_default_executor` fence, because an executor that would never be used is refused rather than -silently accepted. TLS a library performs in memory reaches no loop API -and so reaches no fence; what that means in practice is in +silently accepted. TLS is not in that list either, on either of its two +routes: through the loop, as the table above describes, and inside a +library's own memory BIO, which reaches no loop API at all and now finds a +simulated peer that speaks TLS. What that means in practice is in [docs/compatibility.md](compatibility.md). The socket calls are fenced with one exception. `sock_connect` on an @@ -147,6 +154,22 @@ differ from the ones 0.1.0 recorded — see the [changelog](../CHANGELOG.md). What a hash promises is unchanged: same seed, same code, same interpreter, same hash. +TLS costs that promise one clause, and only for a workload that uses it. TLS +records add no event kind — they are ordinary packets — but how many packets +a handshake makes is a property of the TLS engine, so for such a workload the +promise reads *same seed, same code, same interpreter, same OpenSSL build, +same TLS configuration*. Certificates are not on that list, which is the +reassuring half and is measured: an EC leaf and an RSA leaf record the +same hash, because the trace hashes the number and order of packets and never +their bytes, and the TLS engine emits exactly one write per flight. What does +move it is anything that changes the flight structure — `SSLContext.num_tickets` +(setting it to 0 drops one server packet from a connection that stays open +long enough to be sent its session tickets; raising it above the default of 2 +changes nothing, because they all leave in one write), a client certificate +request, a peer that only speaks TLS 1.2. A run that +never asks for TLS makes no new draw, arms no new timer and records no new +event, which a pinned reference hash in the test suite keeps true. + `simloop.timeline_html(events, limit=5000)` renders a trace as a self-contained HTML page — one lane per machine plus one for the simulation, a dot per scheduling decision, an arrow for every `send` its `deliver` diff --git a/probes/_http.py b/probes/_http.py index 040e374..90d7839 100644 --- a/probes/_http.py +++ b/probes/_http.py @@ -26,6 +26,9 @@ async def respond(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> await reader.readuntil(b"\r\n\r\n") writer.write(RESPONSE) await writer.drain() - writer.write_eof() + # TLS has no half-close, so a TLS transport refuses one; the close below + # is what ends the response there. + if writer.can_write_eof(): + writer.write_eof() writer.close() await writer.wait_closed() diff --git a/probes/_tls.py b/probes/_tls.py new file mode 100644 index 0000000..c08769d --- /dev/null +++ b/probes/_tls.py @@ -0,0 +1,38 @@ +"""Throwaway certificates for the TLS probes, minted in memory. + +A simulation's hostnames — ``web``, ``ws`` — are names no public authority +would ever sign, so the probes issue their own and trust nothing else. + +trustme is imported inside the functions on purpose: probe modules are +imported with the ``probes`` dependency group absent, so nothing reachable +from one may import a third-party package at module scope. +""" + +from __future__ import annotations + +import functools +import ssl +from typing import Any + + +@functools.lru_cache(maxsize=1) +def _authority() -> Any: + import trustme + + return trustme.CA(key_type=trustme.KeyType.ECDSA) + + +def server_context(*names: str) -> ssl.SSLContext: + """A listener's context, presenting a leaf issued for ``names``.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + _authority().issue_cert(*names).configure_cert(context) + return context + + +def client_context() -> ssl.SSLContext: + """A client's context, verifying against this session's authority alone.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = True + _authority().configure_trust(context) + return context diff --git a/probes/probe_aiohttp_https.py b/probes/probe_aiohttp_https.py new file mode 100644 index 0000000..9ffd90c --- /dev/null +++ b/probes/probe_aiohttp_https.py @@ -0,0 +1,52 @@ +"""Probe: aiohttp's client session issuing one GET over https at a sim host. + +The connector reaches the network the same way it does over plain http — a +real descriptor through sock_connect, then create_connection — but this time +with ``ssl`` and ``server_hostname`` riding alongside the socket, and it reads +the TLS layer back out of the transport afterwards. +""" + +from __future__ import annotations + +import asyncio + +from probes import _http, _tls +from simloop import SimLoop + +LIBRARY = "aiohttp (client, https)" +DISTRIBUTION = "aiohttp" +TIER = 2 +NOTES = "ClientSession GET over https at a sim host with a minted certificate." + +PORT = 8443 + + +async def probe(loop: SimLoop) -> str: + import aiohttp + + certificate = _tls.server_context("web") + trust = _tls.client_context() + + async def listen() -> asyncio.AbstractServer: + return await asyncio.start_server( + _http.respond, "0.0.0.0", PORT, ssl=certificate + ) + + async def request() -> str: + async with aiohttp.ClientSession() as session: + async with session.get( + f"https://web:{PORT}/hello", ssl=trust + ) as response: + text: str = await response.text() + return text + + server = await loop.net.host("web").create_task(listen()) + try: + body = await loop.net.host("client").create_task(request()) + finally: + # A TLS close-notify exchange costs a round trip, so the responder + # needs a moment to finish before the run ends. + await asyncio.sleep(1.0) + server.close() + await server.wait_closed() + return f"ClientSession GET over https returned {body!r}" diff --git a/probes/probe_httpx_https.py b/probes/probe_httpx_https.py new file mode 100644 index 0000000..732b63c --- /dev/null +++ b/probes/probe_httpx_https.py @@ -0,0 +1,50 @@ +"""Probe: httpx's async client issuing one GET over https at a sim host. + +httpx reaches no loop TLS API at all — httpcore wraps the byte stream with +anyio's memory-BIO TLSStream, in process — so this row says something the +aiohttp one cannot: that a library running its own TLS engine now has a +simulated peer to speak TLS to. +""" + +from __future__ import annotations + +import asyncio + +from probes import _http, _tls +from simloop import SimLoop + +LIBRARY = "httpx (https)" +DISTRIBUTION = "httpx" +TIER = 2 +NOTES = "AsyncClient GET over https; the TLS engine is anyio's, not the loop's." + +PORT = 8443 + + +async def probe(loop: SimLoop) -> str: + import httpx + + certificate = _tls.server_context("web") + trust = _tls.client_context() + + async def listen() -> asyncio.AbstractServer: + return await asyncio.start_server( + _http.respond, "0.0.0.0", PORT, ssl=certificate + ) + + async def request() -> str: + async with httpx.AsyncClient(verify=trust) as client: + response = await client.get(f"https://web:{PORT}/hello") + text: str = response.text + return text + + server = await loop.net.host("web").create_task(listen()) + try: + body = await loop.net.host("client").create_task(request()) + finally: + # A TLS close-notify exchange costs a round trip, so the responder + # needs a moment to finish before the run ends. + await asyncio.sleep(1.0) + server.close() + await server.wait_closed() + return f"AsyncClient GET over https returned {body!r}" diff --git a/probes/probe_websockets.py b/probes/probe_websockets.py index 5cf6da8..7c81ddf 100644 --- a/probes/probe_websockets.py +++ b/probes/probe_websockets.py @@ -1,8 +1,9 @@ """Probe: a websockets server and client talking to each other in simulation. Both ends are the library's own asyncio implementation, on separate sim hosts, -so the handshake and every frame cross the simulated network. No TLS: it is -fenced, and this probe deliberately stays away from it. +so the handshake and every frame cross the simulated network. Plain ws:// on +purpose, so a verdict here is about the library and not about TLS; the wss:// +row covers that. """ from __future__ import annotations diff --git a/probes/probe_websockets_wss.py b/probes/probe_websockets_wss.py new file mode 100644 index 0000000..f507358 --- /dev/null +++ b/probes/probe_websockets_wss.py @@ -0,0 +1,51 @@ +"""Probe: a websockets server and client talking over wss:// in simulation. + +Both ends are the library's own asyncio implementation, on separate sim +hosts, so this is the one probe that drives ``create_server(ssl=...)`` and +``create_connection(ssl=...)`` in a single run. +""" + +from __future__ import annotations + +from typing import Any + +from probes import _tls +from simloop import SimLoop + +LIBRARY = "websockets (wss)" +DISTRIBUTION = "websockets" +TIER = 1 +NOTES = "asyncio server and client on two sim hosts, over wss://." + +PORT = 8443 + + +async def probe(loop: SimLoop) -> str: + from websockets.asyncio.client import connect + from websockets.asyncio.server import serve + + certificate = _tls.server_context("ws") + trust = _tls.client_context() + + async def echo(connection: Any) -> None: + async for message in connection: + await connection.send(message.upper()) + + async def listen() -> Any: + # Awaiting the server object is what starts it; doing that inside a + # host task is what binds the listener to that host. + return await serve(echo, "0.0.0.0", PORT, ssl=certificate) + + async def talk() -> str: + async with connect(f"wss://ws:{PORT}/", ssl=trust) as client: + await client.send("hello") + reply: str = await client.recv() + return reply + + server = await loop.net.host("ws").create_task(listen()) + try: + reply = await loop.net.host("client").create_task(talk()) + finally: + server.close() + await server.wait_closed() + return f"handshake, one echoed frame ({reply!r}) and close over wss://" diff --git a/pyproject.toml b/pyproject.toml index 3014eda..d768b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ Repository = "https://github.com/dhruvl/simloop" Issues = "https://github.com/dhruvl/simloop/issues" [dependency-groups] -dev = ["pytest>=8", "mypy>=1.11", "hypothesis>=6"] +dev = ["pytest>=8", "mypy>=1.11", "hypothesis>=6", "trustme>=1.2"] # Third-party libraries the compatibility probes drive. Dev-only and pinned # exactly, so docs/compatibility.md's version column describes what actually # ran; never installed by a plain `uv run pytest`. @@ -34,6 +34,7 @@ probes = [ "anyio==4.14.2", "httpx==0.28.1", "websockets==17.0.1", + "trustme==1.2.1", ] [project.entry-points.pytest11] diff --git a/src/simloop/_loop.py b/src/simloop/_loop.py index fa27651..23e0ebd 100644 --- a/src/simloop/_loop.py +++ b/src/simloop/_loop.py @@ -59,7 +59,8 @@ def _fence(api: str) -> NoReturn: def _reject_kwargs(api: str, kwargs: dict[str, Any]) -> None: - # Optional stdlib arguments (ssl, sock, interface selectors, ...) reach + # Whatever is left after the simulated arguments have been taken — + # interface selectors, address-family choices, connection racing — reaches # outside the simulation; anything actually requested must fail loudly. for name, value in kwargs.items(): if value: @@ -673,8 +674,28 @@ async def create_connection( port: Any = None, **kwargs: Any, ) -> Any: + ssl_arg = kwargs.pop("ssl", None) + server_hostname = kwargs.pop("server_hostname", None) + handshake_timeout = kwargs.pop("ssl_handshake_timeout", None) + shutdown_timeout = kwargs.pop("ssl_shutdown_timeout", None) sock = kwargs.pop("sock", None) _reject_kwargs("create_connection", kwargs) + # The stdlib's argument rules, checked against the host the caller + # named rather than the one a parked socket carries: a certificate is + # verified against the name the caller asked for, never one inferred + # on their behalf. + if server_hostname is not None and not ssl_arg: + raise ValueError("server_hostname is only meaningful with ssl") + if server_hostname is None and ssl_arg: + if not host: + raise ValueError( + "You must set server_hostname when using ssl without a host" + ) + server_hostname = host + if handshake_timeout is not None and not ssl_arg: + raise ValueError("ssl_handshake_timeout is only meaningful with ssl") + if shutdown_timeout is not None and not ssl_arg: + raise ValueError("ssl_shutdown_timeout is only meaningful with ssl") if sock is not None: # The stdlib treats a passed-in socket as already connected and # takes ownership of it. Here "connected" means sock_connect @@ -691,7 +712,20 @@ async def create_connection( ) sock.close() host, port = target - return await self._net._open_connection(protocol_factory, host, port) + if not ssl_arg: + return await self._net._open_connection(protocol_factory, host, port) + from simloop import _tls + + return await _tls.connect( + self, + protocol_factory, + host, + port, + _tls.context(ssl_arg, server_side=False), + server_hostname, + handshake_timeout, + shutdown_timeout, + ) async def create_server( self, @@ -700,10 +734,55 @@ async def create_server( port: Any = None, **kwargs: Any, ) -> Any: + ssl_arg = kwargs.pop("ssl", None) + handshake_timeout = kwargs.pop("ssl_handshake_timeout", None) + shutdown_timeout = kwargs.pop("ssl_shutdown_timeout", None) kwargs.pop("backlog", None) # accepted and irrelevant: no accept queue _reject_kwargs("create_server", kwargs) + if handshake_timeout is not None and not ssl_arg: + raise ValueError("ssl_handshake_timeout is only meaningful with ssl") + if shutdown_timeout is not None and not ssl_arg: + raise ValueError("ssl_shutdown_timeout is only meaningful with ssl") + if ssl_arg: + from simloop import _tls + + protocol_factory = _tls.server_factory( + self, + protocol_factory, + _tls.context(ssl_arg, server_side=True), + handshake_timeout, + shutdown_timeout, + ) return await self._net._start_server(protocol_factory, host, port) + async def start_tls( + self, + transport: Any, + protocol: Any, + sslcontext: Any, + *, + server_side: bool = False, + server_hostname: str | None = None, + ssl_handshake_timeout: float | None = None, + ssl_shutdown_timeout: float | None = None, + ) -> Any: + """Upgrade an established simulated connection to TLS. + + Returns the new transport the protocol should write to from here. + """ + from simloop import _tls + + return await _tls.upgrade( + self, + transport, + protocol, + sslcontext, + server_side=server_side, + server_hostname=server_hostname, + handshake_timeout=ssl_handshake_timeout, + shutdown_timeout=ssl_shutdown_timeout, + ) + async def getaddrinfo( self, host: Any, @@ -834,9 +913,6 @@ def shutdown_asyncgens(self, *args: Any, **kwargs: Any) -> Any: def shutdown_default_executor(self, *args: Any, **kwargs: Any) -> Any: _fence("shutdown_default_executor") - def start_tls(self, *args: Any, **kwargs: Any) -> Any: - _fence("start_tls") - def sendfile(self, *args: Any, **kwargs: Any) -> Any: _fence("sendfile") diff --git a/src/simloop/_net.py b/src/simloop/_net.py index 37bdde2..6e58e78 100644 --- a/src/simloop/_net.py +++ b/src/simloop/_net.py @@ -199,13 +199,22 @@ def sockets(self) -> tuple[Any, ...]: return () def close_clients(self) -> None: - """Close every connection this server accepted.""" + """Close every connection this server accepted. + + These reach the connection underneath, so on a TLS server the peer + gets the teardown without a close-notify first — which is what an + abrupt server-side hangup looks like anyway. + """ for transport in list(self._net._streams.values()): if transport._local == (self._host, self._port): transport.close() def abort_clients(self) -> None: - """Reset every connection this server accepted.""" + """Reset every connection this server accepted. + + A reset on a TLS connection sends no close-notify either, which is + exactly what a reset means. + """ for transport in list(self._net._streams.values()): if transport._local == (self._host, self._port): transport.abort() diff --git a/src/simloop/_tls.py b/src/simloop/_tls.py new file mode 100644 index 0000000..c6ebe76 --- /dev/null +++ b/src/simloop/_tls.py @@ -0,0 +1,205 @@ +"""Real TLS over the simulated stream transports, with no descriptor anywhere. + +The handshake is the standard library's: ``asyncio.sslproto.SSLProtocol`` +drives an ``ssl.SSLObject`` over a pair of memory BIOs, and each flight it +produces leaves as one ordinary simulated packet, paying the same seeded +latency every other packet pays. Certificate verification is the real thing, +and the handshake and shutdown deadlines are ordinary loop timers, so they +fire in virtual time. + +This is the only module that imports ``ssl``. ``_loop`` imports it lazily, so +``import simloop`` and every run that never asks for TLS stay free of it. +""" + +from __future__ import annotations + +import ssl +from asyncio import sslproto +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from simloop._loop import SimLoop + +# typeshed types the waiter as required and the handshake timeout as an int; +# sslproto accepts no waiter at all (it returns from _wakeup_waiter before +# touching a None one) and treats both timeouts as floats. Constructing +# through one untyped alias keeps that disagreement in a single place instead +# of scattering ignores over every call. +_SSLProtocol: Any = sslproto.SSLProtocol + + +def context(value: object, *, server_side: bool) -> ssl.SSLContext | None: + """The context an ``ssl=`` argument asks for, or None for the stdlib default. + + ``ssl=True`` means whatever context sslproto would build, which exists + only for a client: a server has no default certificate to present, so + that combination is refused when the listener is created rather than once + per connection it accepts. + """ + if isinstance(value, bool): + if server_side: + raise ValueError("Server side SSL needs a valid SSLContext") + return None + if isinstance(value, ssl.SSLContext): + return value + # Named here rather than left to surface as an AttributeError from inside + # the OpenSSL glue several steps later. + raise TypeError( + "ssl argument must be True or an instance of ssl.SSLContext, " + f"not {type(value).__name__!r}" + ) + + +async def connect( + loop: SimLoop, + protocol_factory: Any, + host: Any, + port: Any, + sslcontext: ssl.SSLContext | None, + server_hostname: str | None, + handshake_timeout: float | None, + shutdown_timeout: float | None, +) -> tuple[Any, Any]: + """Open a simulated connection and hand it to a TLS client handshake.""" + waiter: Any = loop.create_future() + upgrade: dict[str, Any] = {} + + def factory() -> Any: + app = protocol_factory() + protocol = _SSLProtocol( + loop, + app, + sslcontext, + waiter, + False, + server_hostname, + ssl_handshake_timeout=handshake_timeout, + ssl_shutdown_timeout=shutdown_timeout, + ) + # Captured at construction because SSLProtocol.connection_lost drops + # both references before it wakes the waiter: a connection torn down + # during the handshake must still hand back the transport the standard + # library would have handed back, not None. + upgrade["transport"] = protocol._app_transport + upgrade["protocol"] = app + return protocol + + try: + # The application protocol is built inside the factory, so a refused + # connection never constructs one — the plaintext path's rule too. + raw, _ = await loop._net._open_connection(factory, host, port) + except BaseException: + # Nothing was ever built, so nothing will complete this future. + waiter.cancel() + raise + try: + await waiter + except BaseException: + raw.close() + raise + return upgrade["transport"], upgrade["protocol"] + + +def server_factory( + loop: SimLoop, + protocol_factory: Any, + sslcontext: ssl.SSLContext | None, + handshake_timeout: float | None, + shutdown_timeout: float | None, +) -> Callable[[], Any]: + """Wrap a listener's factory so every connection it accepts speaks TLS.""" + + def factory() -> Any: + # The waiter is None on purpose. Accepting is synchronous, so there is + # no coroutine to hand a handshake failure to, and a future carrying an + # exception nobody retrieves would reach Future.__del__ and from there + # the loop's unhandled-error list — failing an otherwise green run + # because some client presented a bad certificate. A server-side + # failure reaches _fatal_error instead, which only debug-logs an + # OSError, and ssl.SSLError is one: the connection is reset and the run + # stays green. + return _SSLProtocol( + loop, + protocol_factory(), + sslcontext, + None, + True, + None, + ssl_handshake_timeout=handshake_timeout, + ssl_shutdown_timeout=shutdown_timeout, + ) + + return factory + + +def _migrate_buffered(protocol: Any, ssl_protocol: Any, *, server_side: bool) -> None: + """Hand a stream reader's already-read bytes to the TLS engine. + + A server that read a plaintext marker off the wire can easily have read + the ClientHello sitting behind it into the reader's own buffer, where the + handshake would never see it and would stall until its deadline. + """ + if not server_side: + return + from asyncio.streams import StreamReaderProtocol + + if not isinstance(protocol, StreamReaderProtocol): + return + reader = getattr(protocol, "_stream_reader", None) + if reader is None: + return + buffer = reader._buffer + if buffer: + ssl_protocol._incoming.write(buffer) + buffer.clear() + + +async def upgrade( + loop: SimLoop, + transport: Any, + protocol: Any, + sslcontext: Any, + *, + server_side: bool, + server_hostname: str | None, + handshake_timeout: float | None, + shutdown_timeout: float | None, +) -> Any: + """Turn an established plaintext connection into a TLS one, in place.""" + if not isinstance(sslcontext, ssl.SSLContext): + raise TypeError( + "sslcontext is expected to be an instance of ssl.SSLContext, " + f"got {sslcontext!r}" + ) + if not getattr(transport, "_start_tls_compatible", False): + raise TypeError(f"transport {transport!r} is not supported by start_tls()") + + waiter: Any = loop.create_future() + ssl_protocol = _SSLProtocol( + loop, + protocol, + sslcontext, + waiter, + server_side, + server_hostname, + call_connection_made=False, + ssl_handshake_timeout=handshake_timeout, + ssl_shutdown_timeout=shutdown_timeout, + ) + # Paused before anything is swapped, so no packet can reach the new + # protocol ahead of its own connection_made. + transport.pause_reading() + _migrate_buffered(protocol, ssl_protocol, server_side=server_side) + transport.set_protocol(ssl_protocol) + made = loop.call_soon(ssl_protocol.connection_made, transport) + resumed = loop.call_soon(transport.resume_reading) + try: + await waiter + except BaseException: + transport.close() + made.cancel() + resumed.cancel() + raise + app_transport: Any = ssl_protocol._app_transport + return app_transport diff --git a/src/simloop/_transports.py b/src/simloop/_transports.py index 90dc657..fab19b2 100644 --- a/src/simloop/_transports.py +++ b/src/simloop/_transports.py @@ -195,6 +195,11 @@ class _SimStreamTransport(asyncio.Transport): None of it applies until ``net.set_flow_control()`` arms it. """ + # asyncio's start_tls refuses a transport that does not advertise this; + # the pause/resume/set_protocol/_force_close contract it relies on is + # implemented below. + _start_tls_compatible = True + def __init__( self, net: SimNetwork, conn: int, local: _Addr, remote: _Addr ) -> None: @@ -204,6 +209,7 @@ def __init__( self._local = local self._remote = remote self._protocol: Any = None + self._buffered = False self._out_seq = 1 # seq 0 was this direction's handshake packet self._closing = False self._closed = False @@ -220,9 +226,17 @@ def __init__( self._peer_closed = False # the peer's FIN or RST has arrived def _begin(self, protocol: Any) -> None: - self._protocol = protocol + self._adopt(protocol) protocol.connection_made(self) + def _adopt(self, protocol: Any) -> None: + # One place decides how a protocol is fed, because start_tls swaps the + # protocol on a live transport and the two entry points must not drift. + # asyncio's SSLProtocol is a BufferedProtocol: it is handed bytes + # through a buffer it owns rather than through data_received. + self._protocol = protocol + self._buffered = isinstance(protocol, asyncio.BufferedProtocol) + # ------------------------------------------------------------------ # Outbound # ------------------------------------------------------------------ @@ -274,12 +288,21 @@ def close(self) -> None: self._send("fin") self._net._loop.call_soon(self._finish, None) - def abort(self) -> None: + def _force_close(self, exc: Exception | None) -> None: + """Tear the connection down now, reporting ``exc`` to the protocol. + + asyncio's SSLProtocol calls this on the transport beneath it when a + handshake fails or an application aborts: the same reset ``abort`` + sends, with the failure carried into connection_lost instead of None. + """ if self._closed: return self._closing = True self._send("rst") - self._net._loop.call_soon(self._finish, None) + self._net._loop.call_soon(self._finish, exc) + + def abort(self) -> None: + self._force_close(None) def _finish(self, exc: Exception | None) -> None: if self._closed: @@ -296,6 +319,7 @@ def _finish(self, exc: Exception | None) -> None: if self._extra_socket is not None: self._extra_socket._dispose() protocol, self._protocol = self._protocol, None + self._buffered = False if protocol is not None: protocol.connection_lost(exc) @@ -391,13 +415,33 @@ def _report_failure(self, message: str, exc: BaseException) -> None: # Inbound (called by the network, already in seq order) # ------------------------------------------------------------------ + def _deliver(self, data: bytes) -> None: + """Hand one packet's bytes to the protocol, however it takes them.""" + if not self._buffered: + self._protocol.data_received(data) + return + # The standard library's asyncio.protocols._feed_data_to_buffered_proto, + # written out rather than imported: get_buffer may answer with less room + # than the packet holds, and an empty buffer is a protocol bug rather + # than a zero-length write, so it must be said out loud. + view = memoryview(data) + while view: + buffer = self._protocol.get_buffer(len(view)) + room = len(buffer) + if not room: + raise RuntimeError("get_buffer() returned an empty buffer") + taken = min(room, len(view)) + buffer[:taken] = view[:taken] + self._protocol.buffer_updated(taken) + view = view[taken:] + def _data_arrived(self, data: bytes) -> None: if self._closed: return if self._read_paused: self._backlog.append(data) return - self._protocol.data_received(data) + self._deliver(data) # Credited after the call, not before: the sender is released only once # the receiving protocol has finished with the bytes. self._consumed(len(data)) @@ -440,7 +484,7 @@ def resume_reading(self) -> None: self._read_paused = False while self._backlog and not self._read_paused and not self._closed: chunk = self._backlog.pop(0) - self._protocol.data_received(chunk) + self._deliver(chunk) # Per chunk, so a protocol that pauses again mid-drain leaves the # rest of the backlog charged to the sender. self._consumed(len(chunk)) @@ -475,7 +519,7 @@ def get_extra_info(self, name: str, default: Any = None) -> Any: return default def set_protocol(self, protocol: Any) -> None: - self._protocol = protocol + self._adopt(protocol) def get_protocol(self) -> Any: return self._protocol diff --git a/tests/_tls_certs.py b/tests/_tls_certs.py new file mode 100644 index 0000000..f575c4d --- /dev/null +++ b/tests/_tls_certs.py @@ -0,0 +1,44 @@ +"""Throwaway certificates for the TLS tests, minted in memory. + +Nothing reaches the disk and no key material is committed: one authority is +created per session and issues leaves for whatever sim hostnames a test +invents. ECDSA keys, because RSA keygen would be paid on every use. +""" + +from __future__ import annotations + +import functools +import ssl +from typing import Any + + +@functools.lru_cache(maxsize=1) +def _authority() -> Any: + import trustme + + return trustme.CA(key_type=trustme.KeyType.ECDSA) + + +def forget() -> None: + """Drop the cached authority so the next context mints a fresh one.""" + _authority.cache_clear() + + +def server_context(*names: str) -> ssl.SSLContext: + """A listener's context, presenting a leaf issued for ``names``.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + _authority().issue_cert(*names).configure_cert(context) + return context + + +def client_context() -> ssl.SSLContext: + """A connector's context: real verification against this session's CA. + + It trusts that authority and nothing else, so a test can never pass + because the machine it runs on happens to trust something. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.verify_mode = ssl.CERT_REQUIRED + context.check_hostname = True + _authority().configure_trust(context) + return context diff --git a/tests/replay_tls_workload.py b/tests/replay_tls_workload.py new file mode 100644 index 0000000..f64895f --- /dev/null +++ b/tests/replay_tls_workload.py @@ -0,0 +1,141 @@ +"""Reference TLS workload for replay-stability checks. + +Importable for in-process runs; also runnable as a script — +``python tests/replay_tls_workload.py `` prints one line: +`` ``. One TLS server, three TLS clients on +machines of their own, a jittered link, a partition that heals and a host +crash, so every fault draw is part of the replay proof. The certificate is +minted fresh on every run, which is what makes comparing runs across +processes mean anything. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import ssl +import sys +from typing import Any + +from _tls_certs import client_context, server_context +from simloop import Host, SimLoop, sim + +_PORT = 8443 + + +class _Echo(asyncio.Protocol): + """Answers one message in upper case and hangs up.""" + + def __init__(self) -> None: + self._transport: Any = None + + def connection_made(self, transport: Any) -> None: + self._transport = transport + + def data_received(self, data: bytes) -> None: + self._transport.write(data.upper()) + self._transport.close() + + +class _Ask(asyncio.Protocol): + """Sends one message and waits for the answer. + + A connection lost before an answer resolves the future with a marker + rather than an exception: nothing here retrieves an exception a partition + or a crash caused, and an unretrieved one would fail the whole run. + """ + + def __init__(self, message: bytes) -> None: + self._message = message + self.reply: asyncio.Future[bytes] = asyncio.get_running_loop().create_future() + + def connection_made(self, transport: Any) -> None: + transport.write(self._message) + + def data_received(self, data: bytes) -> None: + if not self.reply.done(): + self.reply.set_result(data) + + def connection_lost(self, exc: Exception | None) -> None: + if not self.reply.done(): + self.reply.set_result(b"") + + +async def _ask(name: str, context: ssl.SSLContext) -> str: + running: Any = asyncio.get_running_loop() + message = f"{name}-{sim.uuid4()}".encode() + try: + async with asyncio.timeout(3.0): + transport, protocol = await running.create_connection( + lambda: _Ask(message), + "api", + _PORT, + ssl=context, + server_hostname="api", + ) + reply: bytes = await protocol.reply + transport.close() + return reply.decode() + except (TimeoutError, OSError) as exc: + return type(exc).__name__ + + +async def _chatter(context: ssl.SSLContext) -> None: + running: Any = asyncio.get_running_loop() + while True: + transport, protocol = await running.create_connection( + lambda: _Ask(b"noise"), + "api", + _PORT, + ssl=context, + server_hostname="api", + ) + await protocol.reply + transport.close() + await asyncio.sleep(0.05) + + +async def _main(loop: SimLoop, hosts: dict[str, Host]) -> str: + net = loop.net + certificate = server_context("api") + + async def serve() -> Any: + running: Any = asyncio.get_running_loop() + return await running.create_server(_Echo, "0.0.0.0", _PORT, ssl=certificate) + + server = await hosts["api"].create_task(serve()) + hosts["noisy"].create_task(_chatter(client_context())) + tasks = [ + hosts[name].create_task(_ask(name, client_context())) + for name in ("one", "two", "three") + ] + + loop.call_later(0.05, net.partition, {"two"}, {"api"}) + loop.call_later(0.40, net.heal) + loop.call_later(0.10, net.crash, "noisy") + + results = [await task for task in tasks] + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return repr(results) + + +def run(seed: int) -> str: + loop = SimLoop(seed) + net = loop.net + hosts = { + name: net.host(name) for name in ("api", "one", "two", "three", "noisy") + } + net.set_defaults(latency=(0.001, 0.02)) + net.set_link("three", "api", latency=(0.005, 0.05)) + try: + outcome = loop.run_until_complete(_main(loop, hosts)) + finally: + loop.close() + digest = hashlib.sha256(outcome.encode()).hexdigest() + return f"{loop.trace_hash()} {digest}" + + +if __name__ == "__main__": + print(run(int(sys.argv[1]))) diff --git a/tests/test_hardening.py b/tests/test_hardening.py index f90b93f..dddd7aa 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -1,4 +1,4 @@ -"""Long-running replay-stability checks (marked slow; CI runs them).""" +"""Replay-stability checks for the loop-only workload (slow ones run in CI).""" from __future__ import annotations @@ -39,6 +39,29 @@ def _run_child(seed: int, hashseed: str | None) -> str: return result.stdout.strip() +# What this workload recorded before TLS existed. It never asks for TLS, and a +# run that does not ask for a feature has to decide exactly what it decided +# without it — the same draws, the same timers, the same trace events. A +# deliberate change to the trace format updates these and says so in the +# changelog. +_RECORDED = { + 0: ( + "57ce056be9fbc450b6ce34595584c6b64cd64c69f1df3c8c7191cdab7595c36a " + "f7e191e5a6c84b14734ff477b5da89985d7567cc72c5b8a1725c462cd22625de" + ), + 7: ( + "bdbac6965559225cfa661dbc8be7448665d05abb6b5afaa44fdd8e56384df067 " + "784a003bd907cb4818d1a06511be9bc9bd91bd0609d0b286153b484f1a469406" + ), +} + + +def test_the_recorded_runs_still_replay() -> None: + workload = _load_workload() + for seed, recorded in _RECORDED.items(): + assert workload.run(seed) == recorded, f"seed {seed} no longer replays" + + @pytest.mark.slow def test_hundred_reruns_per_seed_are_stable() -> None: workload = _load_workload() diff --git a/tests/test_net_hardening.py b/tests/test_net_hardening.py index 9b4a301..d8cb545 100644 --- a/tests/test_net_hardening.py +++ b/tests/test_net_hardening.py @@ -39,10 +39,12 @@ def _run_child(seed: int, hashseed: str | None) -> str: return result.stdout.strip() -# Captured before write-side flow control existed. The reference workload -# never arms it, and a run that does not ask for it has to decide exactly what -# it decided before — including every fault draw, so a recorded seed a user -# already has still replays. +# What each seed recorded before the features that came after it: seeds 0-2 +# predate write-side flow control, seed 7 predates TLS. The reference workload +# asks for neither, and a run that does not ask for a feature has to decide +# exactly what it decided without it — including every fault draw, so a +# recorded seed a user already has still replays. A deliberate change to the +# trace format updates these and says so in the changelog. _RECORDED = { 0: ( "d9b64b9f0908ec4ccd605340c35bfe0a140fbdb1c4e5a70cf4e0bf2631b7fd4d " @@ -56,10 +58,14 @@ def _run_child(seed: int, hashseed: str | None) -> str: "6784369a11bfa7dc6f998ff3b606a0b56b0d1d3d55005acabdea44c6e7980b9e " "6a9362b769406c2ddc58bb5dcadac8ddf0b5e86da72fee78edf9e15f67adb748" ), + 7: ( + "90a298401635eb8eefbab0d4c6c70cd420e0e364cb38554599c15946e7eddc67 " + "fdf6002b5f7c3a0a8f67f52051c22cbc4789ea8dda8b47313aa890f88a495134" + ), } -def test_flow_control_off_reproduces_the_recorded_runs() -> None: +def test_the_recorded_runs_still_replay() -> None: workload = _load_workload() for seed, recorded in _RECORDED.items(): assert workload.run(seed) == recorded, f"seed {seed} no longer replays" diff --git a/tests/test_streams.py b/tests/test_streams.py index 05025af..005df44 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -320,6 +320,204 @@ async def connect() -> None: assert isinstance(lost[0], ConnectionResetError) +class _Buffered(asyncio.BufferedProtocol): + """Records what each get_buffer/buffer_updated pair was handed. + + ``room`` is how much space it offers, so a value smaller than a packet + forces the delivery loop to fill it more than once. + """ + + def __init__(self, room: int = 4096) -> None: + self._room = room + self._buffer = bytearray(max(room, 1)) + self.transport: Any = None + self.chunks: list[bytes] = [] + self.lost: list[BaseException | None] = [] + + def connection_made(self, transport: Any) -> None: + self.transport = transport + + def get_buffer(self, sizehint: int) -> memoryview: + return memoryview(self._buffer)[: self._room] + + def buffer_updated(self, nbytes: int) -> None: + self.chunks.append(bytes(self._buffer[:nbytes])) + + def connection_lost(self, exc: Exception | None) -> None: + self.lost.append(exc) + + +def _exchange(loop: SimLoop, factory: Any, writes: list[bytes]) -> None: + """Serve ``factory`` on host ``server`` and write each packet to it.""" + + async def main() -> None: + running = asyncio.get_running_loop() + + async def serve() -> None: + server = await running.create_server(factory, "0.0.0.0", 9000) + async with server: + await asyncio.sleep(10.0) + + async def send() -> None: + transport, _ = await running.create_connection( + asyncio.Protocol, "server", 9000 + ) + for payload in writes: + transport.write(payload) + await asyncio.sleep(1.0) + transport.close() + + serve_task = loop.net.host("server").create_task(serve()) + await asyncio.sleep(0.01) + await loop.net.host("client").create_task(send()) + await asyncio.sleep(1.0) + await _reap(serve_task) + + loop.run_until_complete(main()) + + +def test_a_buffered_protocol_is_fed_one_packet_at_a_time() -> None: + loop = _network() + protocol = _Buffered() + + try: + _exchange(loop, lambda: protocol, [b"one", b"two"]) + finally: + loop.close() + assert protocol.chunks == [b"one", b"two"] + + +def test_a_short_buffer_is_filled_in_successive_chunks() -> None: + loop = _network() + protocol = _Buffered(room=2) + + try: + _exchange(loop, lambda: protocol, [b"abcdef"]) + finally: + loop.close() + assert protocol.chunks == [b"ab", b"cd", b"ef"] + assert b"".join(protocol.chunks) == b"abcdef" + + +def test_an_empty_buffer_is_reported_as_a_protocol_bug() -> None: + loop = _network() + protocol = _Buffered(room=0) + + try: + with pytest.raises(RuntimeError, match="empty buffer"): + _exchange(loop, lambda: protocol, [b"anything"]) + finally: + loop.close() + + +def test_set_protocol_switches_to_buffered_delivery_mid_stream() -> None: + loop = _network() + buffered = _Buffered() + plain: list[bytes] = [] + + class Switcher(asyncio.Protocol): + def __init__(self) -> None: + self.transport: Any = None + + def connection_made(self, transport: Any) -> None: + self.transport = transport + + def data_received(self, data: bytes) -> None: + plain.append(data) + self.transport.set_protocol(buffered) + + try: + _exchange(loop, Switcher, [b"before", b"after"]) + finally: + loop.close() + assert plain == [b"before"] + assert buffered.chunks == [b"after"] + + +def test_a_paused_backlog_drains_through_the_buffered_path() -> None: + loop = _network() + + class Paused(_Buffered): + def connection_made(self, transport: Any) -> None: + super().connection_made(transport) + transport.pause_reading() + + protocol = Paused() + + async def main() -> None: + running = asyncio.get_running_loop() + + async def serve() -> None: + server = await running.create_server(lambda: protocol, "0.0.0.0", 9000) + async with server: + await asyncio.sleep(10.0) + + async def send() -> None: + transport, _ = await running.create_connection( + asyncio.Protocol, "server", 9000 + ) + transport.write(b"held") + transport.write(b"too") + await asyncio.sleep(1.0) + transport.close() + + serve_task = loop.net.host("server").create_task(serve()) + await asyncio.sleep(0.01) + send_task = loop.net.host("client").create_task(send()) + await asyncio.sleep(0.5) + assert protocol.chunks == [] + protocol.transport.resume_reading() + await send_task + await asyncio.sleep(1.0) + await _reap(serve_task) + + try: + loop.run_until_complete(main()) + finally: + loop.close() + assert protocol.chunks == [b"held", b"too"] + + +def test_force_close_reports_the_failure_and_resets_the_peer() -> None: + loop = _network() + failure = ConnectionAbortedError("handshake gave up") + client_lost: list[BaseException | None] = [] + server_lost: list[BaseException | None] = [] + + class Client(asyncio.Protocol): + def connection_lost(self, exc: Exception | None) -> None: + client_lost.append(exc) + + class Server(asyncio.Protocol): + def connection_lost(self, exc: Exception | None) -> None: + server_lost.append(exc) + + async def main() -> None: + running: Any = asyncio.get_running_loop() + + async def serve() -> None: + server = await running.create_server(Server, "0.0.0.0", 9000) + async with server: + await asyncio.sleep(10.0) + + async def connect_and_fail() -> None: + transport, _ = await running.create_connection(Client, "server", 9000) + transport._force_close(failure) + + serve_task = loop.net.host("server").create_task(serve()) + await asyncio.sleep(0.01) + await loop.net.host("client").create_task(connect_and_fail()) + await asyncio.sleep(0.5) + await _reap(serve_task) + + try: + loop.run_until_complete(main()) + finally: + loop.close() + assert client_lost == [failure] + assert len(server_lost) == 1 and isinstance(server_lost[0], ConnectionResetError) + + def test_duplicate_bind_and_foreign_bind_are_rejected() -> None: loop = _network() @@ -341,20 +539,72 @@ async def serve_twice() -> None: loop.close() -def test_ssl_arguments_are_fenced() -> None: - from simloop import SimulationFenceError +def test_ssl_arguments_are_checked_the_way_the_stdlib_checks_them() -> None: + import ssl loop = _network() async def main() -> None: running: Any = asyncio.get_running_loop() - with pytest.raises(SimulationFenceError, match="create_connection"): + with pytest.raises(TypeError, match="'object'"): await running.create_connection( asyncio.Protocol, "server", 9000, ssl=object() ) + with pytest.raises(ValueError, match="server_hostname"): + await running.create_connection( + asyncio.Protocol, "server", 9000, server_hostname="server" + ) + with pytest.raises(ValueError, match="server_hostname"): + await running.create_connection( + asyncio.Protocol, ssl=ssl.create_default_context(), sock=object() + ) + with pytest.raises(ValueError, match="ssl_handshake_timeout"): + await running.create_connection( + asyncio.Protocol, "server", 9000, ssl_handshake_timeout=1.0 + ) + with pytest.raises(ValueError, match="ssl_shutdown_timeout"): + await running.create_connection( + asyncio.Protocol, "server", 9000, ssl_shutdown_timeout=1.0 + ) + with pytest.raises(ValueError, match="valid SSLContext"): + await running.create_server(asyncio.Protocol, "0.0.0.0", 9000, ssl=True) + with pytest.raises(ValueError, match="ssl_handshake_timeout"): + await running.create_server( + asyncio.Protocol, "0.0.0.0", 9000, ssl_handshake_timeout=1.0 + ) try: - loop.run_until_complete(main()) + loop.run_until_complete(loop.net.host("server").create_task(main())) + finally: + loop.close() + + +def test_the_fence_did_not_get_wider_than_tls() -> None: + import socket + import ssl + + from simloop import SimulationFenceError + + loop = _network() + + async def main() -> None: + running: Any = asyncio.get_running_loop() + context = ssl.create_default_context() + with pytest.raises(SimulationFenceError, match="local_addr"): + await running.create_connection( + asyncio.Protocol, "server", 9000, local_addr=("client", 0) + ) + with pytest.raises(SimulationFenceError, match="family"): + await running.create_connection( + asyncio.Protocol, "server", 9000, family=socket.AF_INET6 + ) + with pytest.raises(SimulationFenceError, match="create_datagram_endpoint"): + await running.create_datagram_endpoint( + asyncio.DatagramProtocol, local_addr=("0.0.0.0", 9001), ssl=context + ) + + try: + loop.run_until_complete(loop.net.host("client").create_task(main())) finally: loop.close() diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..e6282f7 --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,758 @@ +"""TLS inside the simulation: real handshakes over simulated packets. + +Every certificate here is minted in memory for a sim hostname, and every +client context verifies against that authority alone — so a passing test says +OpenSSL really agreed, not that verification was turned off. +""" + +from __future__ import annotations + +import asyncio +import socket +import ssl +import time +from typing import Any + +import pytest + +import _tls_certs +from simloop import SimLoop + + +def _network( + seed: int = 0, latency: tuple[float, float] = (0.001, 0.001) +) -> SimLoop: + loop = SimLoop(seed=seed) + loop.net.host("api") + loop.net.host("client") + loop.net.set_defaults(latency=latency) + return loop + + +def _settle(loop: SimLoop) -> None: + """Let queued closes cross the simulated network before teardown.""" + loop.run_until_complete(asyncio.sleep(1.0)) + + +async def _echo_once( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter +) -> None: + writer.write((await reader.read(100)).upper()) + await writer.drain() + writer.close() + + +def _serve(loop: SimLoop, handler: Any = _echo_once, port: int = 443) -> Any: + async def listen() -> Any: + return await asyncio.start_server( + handler, "0.0.0.0", port, ssl=_tls_certs.server_context("api") + ) + + return loop.net.host("api").create_task(listen()) + + +def test_a_verified_handshake_carries_an_echo() -> None: + loop = _network() + + async def main() -> tuple[bytes, Any]: + server = await _serve(loop) + + async def request() -> tuple[bytes, Any]: + reader, writer = await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + cipher = writer.transport.get_extra_info("cipher") + writer.write(b"hello") + await writer.drain() + reply = await reader.read(100) + writer.close() + await writer.wait_closed() + return reply, cipher + + result: tuple[bytes, Any] = await loop.net.host("client").create_task( + request() + ) + server.close() + await server.wait_closed() + return result + + try: + reply, cipher = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert reply == b"HELLO" + assert cipher[1] == "TLSv1.3" + + +def test_extra_info_answers_from_both_layers() -> None: + loop = _network() + context = _tls_certs.client_context() + + async def main() -> dict[str, Any]: + server = await _serve(loop) + + async def request() -> dict[str, Any]: + _, writer = await asyncio.open_connection( + "api", 443, ssl=context, server_hostname="api" + ) + transport = writer.transport + info = { + name: transport.get_extra_info(name) + for name in ( + "ssl_object", + "peercert", + "cipher", + "sslcontext", + "peername", + "sockname", + "socket", + ) + } + writer.close() + await writer.wait_closed() + return info + + info: dict[str, Any] = await loop.net.host("client").create_task(request()) + server.close() + await server.wait_closed() + return info + + try: + info = loop.run_until_complete(main()) + _settle(loop) + # The TLS layer answers for itself... + assert isinstance(info["ssl_object"], ssl.SSLObject) + assert info["peercert"]["subjectAltName"] == (("DNS", "api"),) + assert len(info["cipher"]) == 3 + assert info["sslcontext"] is context + # ...and everything else falls through to the simulated transport. + assert info["peername"] == ("api", 443) + assert info["sockname"][0] == "client" + assert info["socket"].getpeername() == (loop.net.address("api"), 443) + finally: + loop.close() + + +def test_a_tls_connect_costs_two_round_trips() -> None: + loop = _network() + + async def main() -> float: + server = await _serve(loop) + + async def request() -> float: + started = asyncio.get_running_loop().time() + _, writer = await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + elapsed = asyncio.get_running_loop().time() - started + writer.close() + return elapsed + + elapsed: float = await loop.net.host("client").create_task(request()) + server.close() + await server.wait_closed() + return elapsed + + try: + elapsed = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + # syn + accept, then ClientHello + the server's flight. + assert elapsed == pytest.approx(0.004) + + +def _upgrade_writer( + writer: asyncio.StreamWriter, context: ssl.SSLContext, **kwargs: Any +) -> Any: + """The STARTTLS recipe: upgrade in place and rebind the writer.""" + + async def upgrade() -> Any: + running: Any = asyncio.get_running_loop() + transport = writer.transport + new = await running.start_tls( + transport, transport.get_protocol(), context, **kwargs + ) + holder: Any = writer + holder._transport = new + return new + + return upgrade() + + +@pytest.mark.parametrize("linger", [0.0, 0.05]) +def test_start_tls_upgrades_an_established_connection(linger: float) -> None: + # With linger, the server dawdles after its plaintext reply, so the + # ClientHello lands in the stream reader's own buffer before the upgrade + # runs: those bytes must reach the handshake rather than be lost. + loop = _network() + buffered: list[int] = [] + + async def handle( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + assert await reader.readline() == b"STARTTLS\n" + writer.write(b"GO AHEAD\n") + await writer.drain() + await asyncio.sleep(linger) + protocol: Any = writer.transport.get_protocol() + buffered.append(len(protocol._stream_reader._buffer)) + await _upgrade_writer( + writer, _tls_certs.server_context("api"), server_side=True + ) + writer.write((await reader.readline()).upper()) + await writer.drain() + writer.close() + + async def main() -> tuple[bytes, Any]: + async def listen() -> Any: + return await asyncio.start_server(handle, "0.0.0.0", 443) + + server = await loop.net.host("api").create_task(listen()) + + async def request() -> tuple[bytes, Any]: + reader, writer = await asyncio.open_connection("api", 443) + writer.write(b"STARTTLS\n") + await writer.drain() + assert await reader.readline() == b"GO AHEAD\n" + upgraded = await _upgrade_writer( + writer, _tls_certs.client_context(), server_hostname="api" + ) + cipher = upgraded.get_extra_info("cipher") + writer.write(b"secret\n") + await writer.drain() + reply = await reader.readline() + writer.close() + await writer.wait_closed() + return reply, cipher + + result: tuple[bytes, Any] = await loop.net.host("client").create_task( + request() + ) + server.close() + await server.wait_closed() + return result + + try: + reply, cipher = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert reply == b"SECRET\n" + assert cipher[1] == "TLSv1.3" + assert (buffered[0] > 0) is (linger > 0.0) + + +def test_a_certificate_for_another_name_is_rejected() -> None: + loop = _network() + + async def main() -> BaseException: + server = await _serve(loop, handler=_hold_open) + + async def request() -> BaseException: + with pytest.raises(ssl.SSLCertVerificationError) as caught: + await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="evil" + ) + return caught.value + + failure: BaseException = await loop.net.host("client").create_task( + request() + ) + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return failure + + try: + failure = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert isinstance(failure, ssl.CertificateError) + assert "Hostname mismatch" in str(failure) + + +async def _hold_open( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter +) -> None: + try: + await reader.read() + except ConnectionResetError: + pass + + +def test_a_silent_peer_aborts_the_handshake_at_its_deadline() -> None: + loop = _network() + + async def main() -> tuple[str, float]: + async def listen() -> Any: + return await asyncio.start_server(_hold_open, "0.0.0.0", 443) + + server = await loop.net.host("api").create_task(listen()) + + async def request() -> tuple[str, float]: + running = asyncio.get_running_loop() + started = running.time() + with pytest.raises(ConnectionAbortedError) as caught: + await asyncio.open_connection( + "api", + 443, + ssl=_tls_certs.client_context(), + server_hostname="api", + ssl_handshake_timeout=5.0, + ) + return str(caught.value), running.time() - started + + result: tuple[str, float] = await loop.net.host("client").create_task( + request() + ) + server.close() + await server.wait_closed() + return result + + wall = time.monotonic() + try: + message, elapsed = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert "SSL handshake is taking longer than 5.0 seconds" in message + # The deadline is an ordinary loop timer, so it costs five virtual + # seconds and no wall-clock ones. + assert 4.99 < elapsed < 5.1 + assert time.monotonic() - wall < 2.0 + + +def test_a_plaintext_answer_fails_the_handshake_at_once() -> None: + loop = _network() + + async def talk(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await asyncio.sleep(0.001) + writer.write(b"HTTP/1.1 400 Bad Request\r\n\r\n") + await writer.drain() + + async def main() -> tuple[str, float]: + async def listen() -> Any: + return await asyncio.start_server(talk, "0.0.0.0", 443) + + server = await loop.net.host("api").create_task(listen()) + + async def request() -> tuple[str, float]: + running = asyncio.get_running_loop() + started = running.time() + with pytest.raises(ssl.SSLError) as caught: + await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + return str(caught.value), running.time() - started + + result: tuple[str, float] = await loop.net.host("client").create_task( + request() + ) + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return result + + try: + message, elapsed = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert "WRONG_VERSION_NUMBER" in message + assert elapsed < 1.0 # the answer, not the sixty-second deadline + + +def test_closing_a_tls_connection_exchanges_close_notify() -> None: + loop = _network() + seen: list[Any] = [] + + async def handle( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + try: + seen.append(("read", await reader.read())) + except BaseException as exc: # pragma: no cover - a clean close has none + seen.append(("error", type(exc).__name__)) + + async def main() -> tuple[bool, Any]: + server = await _serve(loop, handler=handle) + + async def request() -> tuple[bool, Any]: + _, writer = await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + # TLS has no half-close, so the stdlib's transport refuses one. + answer = writer.can_write_eof() + with pytest.raises(NotImplementedError): + writer.write_eof() + writer.close() + await writer.wait_closed() + return answer, None + + answer, _ = await loop.net.host("client").create_task(request()) + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return answer, None + + try: + answer, _ = loop.run_until_complete(main()) + _settle(loop) + assert answer is False + assert seen == [("read", b"")] # a clean EOF, not a reset + assert not loop.net._streams # both raw transports are gone + finally: + loop.close() + + +def test_a_peer_that_resets_mid_handshake_is_reported_as_a_reset() -> None: + loop = _network() + + class Rude(asyncio.Protocol): + def connection_made(self, transport: Any) -> None: + transport.abort() + + async def main() -> str: + running: Any = asyncio.get_running_loop() + server = await loop.net.host("api").create_task( + running.create_server(Rude, "0.0.0.0", 443) + ) + + async def request() -> str: + with pytest.raises(ConnectionResetError) as caught: + await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + return str(caught.value) + + message: str = await loop.net.host("client").create_task(request()) + server.close() + await server.wait_closed() + return message + + try: + message = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert message == "Connection reset by peer" + + +def test_a_peer_that_closes_mid_handshake_is_reported_as_a_bare_reset() -> None: + # sslproto completes an unfinished handshake with the ConnectionResetError + # *class*, so the caller gets an instance carrying no message at all. + loop = _network() + + class Polite(asyncio.Protocol): + def connection_made(self, transport: Any) -> None: + transport.close() + + async def main() -> str: + running: Any = asyncio.get_running_loop() + server = await loop.net.host("api").create_task( + running.create_server(Polite, "0.0.0.0", 443) + ) + + async def request() -> str: + with pytest.raises(ConnectionResetError) as caught: + await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + return str(caught.value) + + message: str = await loop.net.host("client").create_task(request()) + server.close() + await server.wait_closed() + return message + + try: + message = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert message == "" + + +def test_aborting_the_raw_transport_mid_handshake_resolves_the_connect() -> None: + # Standard-library behaviour, not a simloop quirk: the waiter is woken + # without an exception, so the connect returns the transport it would have + # returned — already closed — and the application protocol, which never saw + # connection_made, is never told the connection was lost either. Pinned so + # nobody "fixes" it into an error later. + loop = _network() + events: list[str] = [] + + class App(asyncio.Protocol): + def connection_made(self, transport: Any) -> None: + events.append("made") + + def connection_lost(self, exc: Exception | None) -> None: + events.append(f"lost {exc!r}") + + class Hold(asyncio.Protocol): + pass + + async def main() -> Any: + running: Any = asyncio.get_running_loop() + server = await loop.net.host("api").create_task( + running.create_server(Hold, "0.0.0.0", 443) + ) + + async def request() -> Any: + connecting = asyncio.ensure_future( + running.create_connection( + App, + "api", + 443, + ssl=_tls_certs.client_context(), + server_hostname="api", + ) + ) + # After the syn/accept round trip, while the ClientHello is on the + # wire and the handshake is still open. + await asyncio.sleep(0.0025) + for key, raw in list(loop.net._streams.items()): + if key[1] == "client": + raw.abort() + transport, _ = await connecting + return transport + + transport = await loop.net.host("client").create_task(request()) + server.close() + await server.wait_closed() + return transport + + try: + transport = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert transport.is_closing() + assert events == [] + + +def test_a_partition_stalls_the_handshake_until_it_heals() -> None: + loop = _network() + + async def main() -> tuple[bool, bytes]: + server = await _serve(loop) + loop.net.partition({"api"}, {"client"}) + + async def request() -> bytes: + reader, writer = await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + writer.write(b"hello") + await writer.drain() + reply = await reader.read(100) + writer.close() + await writer.wait_closed() + return reply + + task = loop.net.host("client").create_task(request()) + await asyncio.sleep(3.0) + stalled = not task.done() + loop.net.heal() + reply = await task + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return stalled, reply + + try: + stalled, reply = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert stalled # three virtual seconds in, still waiting and not failed + assert reply == b"HELLO" + + +def test_a_partition_that_outlasts_the_deadline_aborts_the_handshake() -> None: + loop = _network() + + class Hold(asyncio.Protocol): + pass + + async def main() -> tuple[str, float]: + running: Any = asyncio.get_running_loop() + + async def listen() -> Any: + return await running.create_server( + Hold, "0.0.0.0", 443, ssl=_tls_certs.server_context("api") + ) + + server = await loop.net.host("api").create_task(listen()) + # After the syn/accept round trip and before the ClientHello lands, so + # the connection is established and only the handshake is cut off. + loop.call_later(0.0025, loop.net.partition, {"api"}, {"client"}) + + async def request() -> tuple[str, float]: + started = running.time() + with pytest.raises(ConnectionAbortedError) as caught: + await asyncio.open_connection( + "api", + 443, + ssl=_tls_certs.client_context(), + server_hostname="api", + ssl_handshake_timeout=5.0, + ) + return str(caught.value), running.time() - started + + result: tuple[str, float] = await loop.net.host("client").create_task( + request() + ) + loop.net.heal() + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return result + + try: + message, elapsed = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert "SSL handshake is taking longer than 5.0 seconds" in message + assert 4.99 < elapsed < 5.1 + + +def test_a_server_side_handshake_failure_leaves_the_run_green() -> None: + loop = _network() + made: list[Any] = [] + + class App(asyncio.Protocol): + def connection_made(self, transport: Any) -> None: + made.append(transport) + + async def main() -> None: + running: Any = asyncio.get_running_loop() + + async def listen() -> Any: + return await running.create_server( + App, "0.0.0.0", 443, ssl=_tls_certs.server_context("api") + ) + + server = await loop.net.host("api").create_task(listen()) + + async def request() -> None: + # The system trust store, which knows nothing of this simulation. + with pytest.raises(ssl.SSLCertVerificationError): + await asyncio.open_connection( + "api", + 443, + ssl=ssl.create_default_context(), + server_hostname="api", + ) + + await loop.net.host("client").create_task(request()) + await asyncio.sleep(5.0) + server.close() + await server.wait_closed() + + try: + loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert made == [] # the rejected client never reached the application + + +def _tls_run(seed: int) -> str: + """Three concurrent TLS clients against one TLS server, hashed.""" + loop = SimLoop(seed=seed) + for name in ("api", "one", "two", "three"): + loop.net.host(name) + loop.net.set_defaults(latency=(0.001, 0.02)) + + async def handle( + reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + writer.write((await reader.read(100)).upper()) + await writer.drain() + writer.close() + + async def request(name: str) -> bytes: + reader, writer = await asyncio.open_connection( + "api", 443, ssl=_tls_certs.client_context(), server_hostname="api" + ) + writer.write(name.encode()) + await writer.drain() + reply = await reader.read(100) + writer.close() + await writer.wait_closed() + return reply + + async def main() -> list[bytes]: + async def listen() -> Any: + return await asyncio.start_server( + handle, "0.0.0.0", 443, ssl=_tls_certs.server_context("api") + ) + + server = await loop.net.host("api").create_task(listen()) + tasks = [ + loop.net.host(name).create_task(request(name)) + for name in ("one", "two", "three") + ] + replies = [await task for task in tasks] + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return replies + + try: + loop.run_until_complete(main()) + finally: + loop.close() + return loop.trace_hash() + + +def test_the_same_seed_gives_the_same_tls_trace() -> None: + for seed in (0, 3, 7): + assert len({_tls_run(seed) for _ in range(5)}) == 1 + assert len({_tls_run(seed) for seed in (0, 3, 7)}) == 3 + + +def test_ssl_rides_alongside_a_parked_socket() -> None: + # The two-call sequence aiohttp performs: resolve, connect a real + # descriptor, then hand it to create_connection with ssl= and + # server_hostname= alongside. + loop = _network() + + async def main() -> tuple[Any, Any]: + server = await _serve(loop, handler=_hold_open) + + async def request() -> tuple[Any, Any]: + running: Any = asyncio.get_running_loop() + infos = await running.getaddrinfo("api", 443, type=socket.SOCK_STREAM) + family, kind, proto, _, address = infos[0] + sock = socket.socket(family=family, type=kind, proto=proto) + sock.setblocking(False) + await running.sock_connect(sock, address) + transport, _ = await running.create_connection( + asyncio.Protocol, + ssl=_tls_certs.client_context(), + server_hostname="api", + sock=sock, + ) + cipher = transport.get_extra_info("cipher") + transport.close() + return sock, cipher + + sock, cipher = await loop.net.host("client").create_task(request()) + await asyncio.sleep(2.0) + server.close() + await server.wait_closed() + return sock, cipher + + try: + sock, cipher = loop.run_until_complete(main()) + _settle(loop) + finally: + loop.close() + assert cipher[1] == "TLSv1.3" + assert sock.fileno() == -1 # the loop took ownership and closed it + assert not loop._sock_targets # the parked entry was claimed diff --git a/tests/test_tls_hardening.py b/tests/test_tls_hardening.py new file mode 100644 index 0000000..a204a81 --- /dev/null +++ b/tests/test_tls_hardening.py @@ -0,0 +1,79 @@ +"""Replay-stability checks for a TLS workload (slow ones run in CI).""" + +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +import _tls_certs + +_SCRIPT = Path(__file__).with_name("replay_tls_workload.py") + + +def _load_workload() -> Any: + spec = importlib.util.spec_from_file_location("replay_tls_workload", _SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run_child(seed: int, hashseed: str | None) -> str: + env = os.environ.copy() + env.pop("PYTHONHASHSEED", None) + if hashseed is not None: + env["PYTHONHASHSEED"] = hashseed + result = subprocess.run( + [sys.executable, str(_SCRIPT), str(seed)], + capture_output=True, + text=True, + env=env, + check=True, + timeout=120, + ) + assert result.stderr == "" + return result.stdout.strip() + + +def test_same_seed_replays_identically() -> None: + workload = _load_workload() + for seed in range(3): + assert workload.run(seed) == workload.run(seed) + + +def test_different_seeds_diverge() -> None: + workload = _load_workload() + assert len({workload.run(seed) for seed in range(3)}) == 3 + + +def test_a_fresh_certificate_does_not_move_the_hash() -> None: + # The trace records how many packets a handshake made and in what order, + # never their bytes, and the TLS engine emits one packet per flight — so + # new key material changes every byte on the wire and nothing in the hash. + workload = _load_workload() + first = workload.run(0) + _tls_certs.forget() + assert workload.run(0) == first + + +@pytest.mark.slow +def test_hundred_tls_reruns_per_seed_are_stable() -> None: + workload = _load_workload() + for seed in range(3): + results = {workload.run(seed) for _ in range(100)} + assert len(results) == 1, f"seed {seed} produced diverging runs" + + +@pytest.mark.slow +def test_tls_replay_is_stable_across_processes_and_hash_seeds() -> None: + workload = _load_workload() + for seed in (0, 7): + results = {_run_child(seed, hs) for hs in (None, "0", "1", "random")} + results.add(workload.run(seed)) + assert len(results) == 1, f"seed {seed} diverged across processes" diff --git a/uv.lock b/uv.lock index 134e024..af435ba 100644 --- a/uv.lock +++ b/uv.lock @@ -200,6 +200,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -209,6 +294,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -742,6 +877,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -777,11 +921,13 @@ dev = [ { name = "hypothesis" }, { name = "mypy" }, { name = "pytest" }, + { name = "trustme" }, ] probes = [ { name = "aiohttp" }, { name = "anyio" }, { name = "httpx" }, + { name = "trustme" }, { name = "websockets" }, ] @@ -792,11 +938,13 @@ dev = [ { name = "hypothesis", specifier = ">=6" }, { name = "mypy", specifier = ">=1.11" }, { name = "pytest", specifier = ">=8" }, + { name = "trustme", specifier = ">=1.2" }, ] probes = [ { name = "aiohttp", specifier = "==3.14.3" }, { name = "anyio", specifier = "==4.14.2" }, { name = "httpx", specifier = "==0.28.1" }, + { name = "trustme", specifier = "==1.2.1" }, { name = "websockets", specifier = "==17.0.1" }, ] @@ -809,6 +957,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "trustme" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/c5/931476f4cf1cd9e736f32651005078061a50dc164a2569fb874e00eb2786/trustme-1.2.1.tar.gz", hash = "sha256:6528ba2bbc7f2db41f33825c8dd13e3e3eb9d334ba0f909713c8c3139f4ae47f", size = 26844, upload-time = "2025-01-02T01:55:32.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/f3/c34dbabf6da5eda56fe923226769d40e11806952cd7f46655dd06e10f018/trustme-1.2.1-py3-none-any.whl", hash = "sha256:d768e5fc57c86dfc5ec9365102e9b092541cd6954b35d8c1eea01a84f35a762a", size = 16530, upload-time = "2025-01-02T01:55:30.181Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"