fizzpy 0.5.0__cp315-cp315-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fizzpy/__init__.py ADDED
@@ -0,0 +1,330 @@
1
+ """fizzpy — post-quantum TLS 1.3 for Python, built on Facebook's Fizz.
2
+
3
+ Two ways to use it. Plug Fizz under an HTTP client you already have — mount
4
+ :class:`fizzpy.contrib.requests.FizzAdapter` on a ``requests.Session``, or wrap
5
+ any connected socket with :func:`fizzpy.wrap_socket` — and keep that client's
6
+ cookies, redirects, and pooling while Fizz does the handshake. Or use the small
7
+ built-in client (this module, sync) / :mod:`fizzpy.aio` (async); both drive the
8
+ same C++ core, which runs every connection on a background EventBase thread.
9
+
10
+ import fizzpy
11
+
12
+ r = fizzpy.get("https://www.cloudflare.com")
13
+ print(r.status_code, r.headers["content-type"])
14
+ print(r.tls) # negotiated TLS 1.3 parameters
15
+
16
+ Certificate chains are verified against the system trust store and the
17
+ hostname is checked against the certificate's SAN by default.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+
23
+ # start delvewheel patch
24
+ def _delvewheel_patch_1_13_2():
25
+ import os
26
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'fizzpy.libs'))):
27
+ os.add_dll_directory(libs_dir)
28
+
29
+
30
+ _delvewheel_patch_1_13_2()
31
+ del _delvewheel_patch_1_13_2
32
+ # end delvewheel patch
33
+
34
+ import threading
35
+ from collections.abc import Mapping, Sequence
36
+ from typing import Any
37
+
38
+ from . import _core
39
+ from ._common import (
40
+ DEFAULT_ALPN,
41
+ DEFAULT_GROUPS,
42
+ DEFAULT_MAX_REDIRECTS,
43
+ DEFAULT_TIMEOUT_MS,
44
+ READ_DONE,
45
+ Extension,
46
+ Target,
47
+ TooManyRedirects,
48
+ default_ca_file,
49
+ next_redirect,
50
+ normalize_extensions,
51
+ parse_url,
52
+ strip_body_headers,
53
+ supports_post_quantum,
54
+ timeout_to_ms,
55
+ )
56
+ from ._core import NamedGroup
57
+ from ._http import Headers, Response, ResponseParser, build_request
58
+ from ._tls import TlsConfig, TlsSocket, wrap_socket
59
+ from ._transport import run_sync
60
+
61
+ __all__ = [
62
+ "Client",
63
+ "Extension",
64
+ "Headers",
65
+ "NamedGroup",
66
+ "Response",
67
+ "TlsConfig",
68
+ "TlsParameters",
69
+ "TlsSocket",
70
+ "TooManyRedirects",
71
+ "delete",
72
+ "get",
73
+ "head",
74
+ "post",
75
+ "put",
76
+ "request",
77
+ "supports_post_quantum",
78
+ "wrap_socket",
79
+ ]
80
+
81
+ # Negotiated parameters are returned from the core as a plain dict; expose the
82
+ # key set as documentation.
83
+ TlsParameters = dict
84
+
85
+
86
+ class _Stale(Exception):
87
+ """Internal: a pooled connection was dead; retry once on a fresh one."""
88
+
89
+
90
+ class Client:
91
+ """A synchronous TLS HTTP client.
92
+
93
+ Idle connections are kept in a per-host pool and reused (HTTP/1.1
94
+ keep-alive). A pooled connection found dead on reuse is transparently
95
+ replaced for idempotent retries. Call :meth:`close` (or use the client as a
96
+ context manager) to close pooled connections.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ *,
102
+ verify: bool = True,
103
+ cafile: str | None = None,
104
+ timeout: float = DEFAULT_TIMEOUT_MS / 1000,
105
+ alpn: list[str] | None = None,
106
+ groups: list[_core.NamedGroup] | None = None,
107
+ extensions: Sequence[tuple[int, bytes]] | None = None,
108
+ follow_redirects: bool = True,
109
+ max_redirects: int = DEFAULT_MAX_REDIRECTS,
110
+ ) -> None:
111
+ self._verify = verify
112
+ self._cafile = cafile or default_ca_file()
113
+ self._timeout_ms = timeout_to_ms(timeout)
114
+ self._alpn = list(alpn) if alpn is not None else list(DEFAULT_ALPN)
115
+ self._groups = list(groups) if groups is not None else list(DEFAULT_GROUPS)
116
+ self._extensions = normalize_extensions(extensions)
117
+ self._follow_redirects = follow_redirects
118
+ self._max_redirects = max_redirects
119
+ self._pool: dict[tuple[str, int], list[_core.TlsConnection]] = {}
120
+ self._lock = threading.Lock()
121
+
122
+ def request(
123
+ self,
124
+ method: str,
125
+ url: str,
126
+ *,
127
+ headers: Mapping[str, str] | None = None,
128
+ body: bytes | None = None,
129
+ ) -> Response:
130
+ method = method.upper()
131
+ seen = 0
132
+ while True:
133
+ response = self._perform(method, url, headers=headers, body=body)
134
+ response.url = url
135
+ nxt = (
136
+ next_redirect(
137
+ method, url, response.status_code, response.headers.get("Location")
138
+ )
139
+ if self._follow_redirects
140
+ else None
141
+ )
142
+ if nxt is None:
143
+ return response
144
+ if seen >= self._max_redirects:
145
+ raise TooManyRedirects(
146
+ f"exceeded {self._max_redirects} redirects (last: {url})"
147
+ )
148
+ new_method, url = nxt
149
+ if new_method != method:
150
+ method, body, headers = new_method, None, strip_body_headers(headers)
151
+ seen += 1
152
+
153
+ def _perform(
154
+ self,
155
+ method: str,
156
+ url: str,
157
+ *,
158
+ headers: Mapping[str, str] | None = None,
159
+ body: bytes | None = None,
160
+ ) -> Response:
161
+ target = parse_url(url)
162
+ key = (target.host, target.port)
163
+ raw = build_request(
164
+ method, target.path, target.host, dict(headers) if headers else None, body
165
+ )
166
+
167
+ pooled = self._take(key)
168
+ if pooled is not None:
169
+ try:
170
+ return self._exchange(pooled, key, raw, reused=True)
171
+ except _Stale:
172
+ pooled.close() # dead idle connection; fall through to a fresh one
173
+
174
+ return self._exchange(self._connect(target), key, raw, reused=False)
175
+
176
+ def _connect(self, target: Target) -> _core.TlsConnection:
177
+ conn = _core.TlsConnection()
178
+ run_sync(
179
+ lambda resolve, reject: conn.connect(
180
+ target.host,
181
+ target.port,
182
+ target.host,
183
+ self._alpn,
184
+ self._groups,
185
+ self._verify,
186
+ self._cafile,
187
+ self._timeout_ms,
188
+ self._extensions,
189
+ resolve,
190
+ reject,
191
+ )
192
+ )
193
+ return conn
194
+
195
+ def _exchange(
196
+ self,
197
+ conn: _core.TlsConnection,
198
+ key: tuple[str, int],
199
+ raw: bytes,
200
+ *,
201
+ reused: bool,
202
+ ) -> Response:
203
+ try:
204
+ run_sync(
205
+ lambda resolve, reject: conn.write(
206
+ self._timeout_ms, raw, resolve, reject
207
+ )
208
+ )
209
+ except Exception as exc:
210
+ # A reused connection that fails before the request lands was closed
211
+ # by the server while idle — safe to retry on a fresh one.
212
+ if reused:
213
+ raise _Stale from exc
214
+ raise
215
+
216
+ parser = ResponseParser()
217
+ received = False
218
+ while not parser.is_complete():
219
+ try:
220
+ chunk = run_sync(
221
+ lambda resolve, reject: conn.read(self._timeout_ms, resolve, reject)
222
+ )
223
+ except Exception as exc:
224
+ if reused and not received:
225
+ raise _Stale from exc
226
+ raise
227
+ if chunk == READ_DONE:
228
+ if reused and not received:
229
+ raise _Stale
230
+ parser.feed_eof()
231
+ break
232
+ received = True
233
+ parser.feed(chunk)
234
+
235
+ response = parser.response
236
+ if response is None:
237
+ if reused:
238
+ raise _Stale
239
+ raise ConnectionError("connection closed before a full response")
240
+ response.tls = conn.negotiated()
241
+
242
+ if self._reusable(parser, response):
243
+ self._give(key, conn)
244
+ else:
245
+ conn.close()
246
+ return response
247
+
248
+ @staticmethod
249
+ def _reusable(parser: ResponseParser, response: Response) -> bool:
250
+ if parser.used_close_framing or response.http_version != "HTTP/1.1":
251
+ return False
252
+ return "close" not in (response.headers.get("Connection") or "").lower()
253
+
254
+ def _take(self, key: tuple[str, int]) -> _core.TlsConnection | None:
255
+ with self._lock:
256
+ conns = self._pool.get(key)
257
+ return conns.pop() if conns else None
258
+
259
+ def _give(self, key: tuple[str, int], conn: _core.TlsConnection) -> None:
260
+ with self._lock:
261
+ self._pool.setdefault(key, []).append(conn)
262
+
263
+ def close(self) -> None:
264
+ """Close all pooled connections."""
265
+ with self._lock:
266
+ pooled = [c for conns in self._pool.values() for c in conns]
267
+ self._pool.clear()
268
+ for conn in pooled:
269
+ conn.close()
270
+
271
+ def get(self, url: str, **kwargs: Any) -> Response:
272
+ return self.request("GET", url, **kwargs)
273
+
274
+ def post(self, url: str, **kwargs: Any) -> Response:
275
+ return self.request("POST", url, **kwargs)
276
+
277
+ def head(self, url: str, **kwargs: Any) -> Response:
278
+ return self.request("HEAD", url, **kwargs)
279
+
280
+ def put(self, url: str, **kwargs: Any) -> Response:
281
+ return self.request("PUT", url, **kwargs)
282
+
283
+ def delete(self, url: str, **kwargs: Any) -> Response:
284
+ return self.request("DELETE", url, **kwargs)
285
+
286
+ def __enter__(self) -> Client:
287
+ return self
288
+
289
+ def __exit__(self, *exc: object) -> None:
290
+ self.close()
291
+
292
+
293
+ _CLIENT_KWARGS = frozenset(
294
+ {
295
+ "verify",
296
+ "cafile",
297
+ "timeout",
298
+ "alpn",
299
+ "groups",
300
+ "extensions",
301
+ "follow_redirects",
302
+ "max_redirects",
303
+ }
304
+ )
305
+
306
+
307
+ def request(method: str, url: str, **kwargs: Any) -> Response:
308
+ """One-shot request with a throwaway :class:`Client`."""
309
+ client_kwargs = {k: kwargs.pop(k) for k in list(kwargs) if k in _CLIENT_KWARGS}
310
+ return Client(**client_kwargs).request(method, url, **kwargs)
311
+
312
+
313
+ def get(url: str, **kwargs: Any) -> Response:
314
+ return request("GET", url, **kwargs)
315
+
316
+
317
+ def post(url: str, **kwargs: Any) -> Response:
318
+ return request("POST", url, **kwargs)
319
+
320
+
321
+ def head(url: str, **kwargs: Any) -> Response:
322
+ return request("HEAD", url, **kwargs)
323
+
324
+
325
+ def put(url: str, **kwargs: Any) -> Response:
326
+ return request("PUT", url, **kwargs)
327
+
328
+
329
+ def delete(url: str, **kwargs: Any) -> Response:
330
+ return request("DELETE", url, **kwargs)
fizzpy/__main__.py ADDED
@@ -0,0 +1,235 @@
1
+ """Command line: ``python -m fizzpy probe HOST...`` (also installed as ``fizzpy``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import os
9
+ import sys
10
+ from collections.abc import Sequence
11
+
12
+ from .probe import (
13
+ NoPostQuantumSupport,
14
+ Outcome,
15
+ ProbeResult,
16
+ Verdict,
17
+ parse_target,
18
+ probe_many,
19
+ )
20
+
21
+ # (column header, group name in ProbeResult.attempts)
22
+ _COLUMNS = (
23
+ ("ML-KEM", "X25519MLKEM768"),
24
+ ("x25519", "x25519"),
25
+ ("P-256", "secp256r1"),
26
+ ("P-384", "secp384r1"),
27
+ )
28
+ _CELL = {Outcome.ACCEPTED: "ok", Outcome.REJECTED: "rej", Outcome.FAILED: "FAIL"}
29
+ _COLOR = {
30
+ Verdict.POST_QUANTUM: "32",
31
+ Verdict.CLASSICAL_ONLY: "33",
32
+ Verdict.PQ_BLOCKED: "31",
33
+ Verdict.NO_TLS13: "31",
34
+ Verdict.UNREACHABLE: "90",
35
+ Verdict.ERROR: "31",
36
+ }
37
+
38
+
39
+ def _paint(text: str, code: str, *, enabled: bool) -> str:
40
+ return f"\033[{code}m{text}\033[0m" if enabled else text
41
+
42
+
43
+ def _cell(result: ProbeResult, group: str) -> str:
44
+ for attempt in result.attempts:
45
+ if attempt.offered == (group,):
46
+ return _CELL[attempt.outcome]
47
+ return "-"
48
+
49
+
50
+ def _row(result: ProbeResult) -> list[str]:
51
+ target = result.host if result.port == 443 else f"{result.host}:{result.port}"
52
+ note = result.error or ""
53
+ return [
54
+ target,
55
+ result.verdict.value,
56
+ result.preferred or "-",
57
+ *(_cell(result, group) for _, group in _COLUMNS),
58
+ note,
59
+ ]
60
+
61
+
62
+ def _print_table(results: Sequence[ProbeResult], *, color: bool) -> None:
63
+ header = ["HOST", "VERDICT", "PREFERRED", *(name for name, _ in _COLUMNS), ""]
64
+ rows = [header, *(_row(r) for r in results)]
65
+ widths = [max(len(row[i]) for row in rows) for i in range(len(header))]
66
+ for i, row in enumerate(rows):
67
+ cells: list[str] = []
68
+ for j, text in enumerate(row):
69
+ padded = text.ljust(widths[j])
70
+ if i > 0 and j == 1:
71
+ padded = _paint(padded, _COLOR[results[i - 1].verdict], enabled=color)
72
+ cells.append(padded)
73
+ print(" ".join(cells).rstrip())
74
+
75
+
76
+ def _print_detail(result: ProbeResult) -> None:
77
+ where = f" ({result.address}, tcp {result.tcp_ms}ms)" if result.address else ""
78
+ print(f"\n{result.host}:{result.port}{where} -> {result.verdict.value}")
79
+ if result.error:
80
+ print(f" error: {result.error}")
81
+ for attempt in (*result.attempts, result.default):
82
+ if attempt is None:
83
+ continue
84
+ label = "default offer" if attempt is result.default else attempt.offered[0]
85
+ line = f" {label:<16} {attempt.outcome.value:<9} {attempt.elapsed_ms:>7}ms"
86
+ if attempt.outcome is Outcome.ACCEPTED:
87
+ line += f" {attempt.group} {attempt.cipher} alpn={attempt.alpn}"
88
+ line += f" cert={attempt.certificate}"
89
+ elif attempt.alert:
90
+ line += f" alert={attempt.alert}"
91
+ if attempt.detail:
92
+ line += f" {attempt.detail}"
93
+ print(line)
94
+
95
+
96
+ def _read_targets(args: argparse.Namespace) -> list[str]:
97
+ raw: list[str] = list(args.targets)
98
+ if args.file:
99
+ if args.file == "-":
100
+ lines = sys.stdin.read().splitlines()
101
+ else:
102
+ with open(args.file, encoding="utf-8-sig") as handle:
103
+ lines = handle.read().splitlines()
104
+ raw += [line.split("#", 1)[0].strip() for line in lines]
105
+ return [t for t in raw if t]
106
+
107
+
108
+ async def _probe_all(
109
+ args: argparse.Namespace, targets: list[tuple[str, int]]
110
+ ) -> list[ProbeResult]:
111
+ results: list[ProbeResult] = []
112
+ async for result in probe_many(
113
+ targets, concurrency=args.concurrency, timeout=args.timeout, sni=args.sni
114
+ ):
115
+ if args.json:
116
+ print(json.dumps(result.to_dict()), flush=True)
117
+ results.append(result)
118
+ return results
119
+
120
+
121
+ def _cmd_probe(args: argparse.Namespace) -> int:
122
+ try:
123
+ # Dedup on the parsed target, so Example.com and example.com:443 are one.
124
+ targets = list(dict.fromkeys(parse_target(t) for t in _read_targets(args)))
125
+ except (OSError, ValueError) as exc:
126
+ print(f"fizzpy: {exc}", file=sys.stderr)
127
+ return 2
128
+ if not targets:
129
+ print("fizzpy: no targets given (pass hosts or --file)", file=sys.stderr)
130
+ return 2
131
+
132
+ try:
133
+ results = asyncio.run(_probe_all(args, targets))
134
+ except NoPostQuantumSupport as exc:
135
+ print(f"fizzpy: {exc}", file=sys.stderr)
136
+ return 2
137
+ except KeyboardInterrupt:
138
+ return 130
139
+
140
+ if not args.json:
141
+ order = {t: i for i, t in enumerate(targets)}
142
+ results.sort(key=lambda r: order.get((r.host, r.port), len(order)))
143
+ # Legacy Windows consoles print escape codes raw, so colour is POSIX-only.
144
+ color = (
145
+ sys.stdout.isatty()
146
+ and sys.platform != "win32"
147
+ and "NO_COLOR" not in os.environ
148
+ )
149
+ _print_table(results, color=color)
150
+ if args.verbose:
151
+ for result in results:
152
+ _print_detail(result)
153
+
154
+ if args.require_pq and any(r.verdict is not Verdict.POST_QUANTUM for r in results):
155
+ return 1
156
+ return 0
157
+
158
+
159
+ def _build_parser() -> argparse.ArgumentParser:
160
+ parser = argparse.ArgumentParser(
161
+ prog="fizzpy", description="Post-quantum TLS 1.3 tools built on Fizz."
162
+ )
163
+ sub = parser.add_subparsers(dest="command", required=True)
164
+
165
+ p = sub.add_parser(
166
+ "probe",
167
+ help="check which TLS 1.3 key exchanges a server accepts",
168
+ description=(
169
+ "Offer one key-exchange group per connection and report which the server "
170
+ "accepts. 'rej' means the server answered with a TLS alert; 'FAIL' means "
171
+ "the connection broke without one (reset, close, timeout). Certificates "
172
+ "are not validated."
173
+ ),
174
+ )
175
+ p.add_argument(
176
+ "targets",
177
+ nargs="*",
178
+ metavar="TARGET",
179
+ help="host, host:port, [v6]:port or https:// URL",
180
+ )
181
+ p.add_argument(
182
+ "-f", "--file", help="read targets from FILE, one per line ('-' for stdin)"
183
+ )
184
+ p.add_argument(
185
+ "--json",
186
+ action="store_true",
187
+ help="print one JSON object per target as it finishes",
188
+ )
189
+ p.add_argument(
190
+ "-v", "--verbose", action="store_true", help="show every handshake in detail"
191
+ )
192
+ p.add_argument(
193
+ "-c", "--concurrency", type=int, default=16, help="hosts probed at once (16)"
194
+ )
195
+ p.add_argument(
196
+ "-t",
197
+ "--timeout",
198
+ type=float,
199
+ default=5.0,
200
+ help="seconds per connect/handshake (5)",
201
+ )
202
+ p.add_argument(
203
+ "--sni",
204
+ metavar="NAME",
205
+ help="server name to send for every target (default: the host; none for IPs)",
206
+ )
207
+ p.add_argument(
208
+ "--require-pq",
209
+ action="store_true",
210
+ help="exit 1 unless every target is post-quantum",
211
+ )
212
+ p.set_defaults(func=_cmd_probe)
213
+ return parser
214
+
215
+
216
+ def main(argv: Sequence[str] | None = None) -> int:
217
+ args = _build_parser().parse_args(argv)
218
+ if args.command == "probe" and (args.concurrency < 1 or args.timeout <= 0):
219
+ print("fizzpy: --concurrency and --timeout must be positive", file=sys.stderr)
220
+ return 2
221
+ # A certificate subject can hold characters the terminal's encoding lacks.
222
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
223
+ if reconfigure is not None:
224
+ reconfigure(errors="replace")
225
+ try:
226
+ return args.func(args)
227
+ except BrokenPipeError:
228
+ # The reader went away (`| head`); aim stdout at devnull so the
229
+ # interpreter's exit-time flush does not raise again.
230
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
231
+ return 1
232
+
233
+
234
+ if __name__ == "__main__":
235
+ sys.exit(main())