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 +330 -0
- fizzpy/__main__.py +235 -0
- fizzpy/_common.py +202 -0
- fizzpy/_core.cp315-win_amd64.pyd +0 -0
- fizzpy/_core.pyi +134 -0
- fizzpy/_http.py +463 -0
- fizzpy/_tls.py +348 -0
- fizzpy/_transport.py +70 -0
- fizzpy/aio.py +240 -0
- fizzpy/contrib/__init__.py +8 -0
- fizzpy/contrib/_context.py +153 -0
- fizzpy/contrib/httpx.py +89 -0
- fizzpy/contrib/requests.py +85 -0
- fizzpy/probe.py +512 -0
- fizzpy-0.5.0.dist-info/DELVEWHEEL +2 -0
- fizzpy-0.5.0.dist-info/METADATA +109 -0
- fizzpy-0.5.0.dist-info/RECORD +25 -0
- fizzpy-0.5.0.dist-info/WHEEL +5 -0
- fizzpy-0.5.0.dist-info/entry_points.txt +3 -0
- fizzpy-0.5.0.dist-info/licenses/LICENSE +21 -0
- fizzpy.libs/gflags-1b4a3a2914960715660effb2d43fe3ad.dll +0 -0
- fizzpy.libs/glog-c0b0b7b033ee49ed5750a1a624e3aedc.dll +0 -0
- fizzpy.libs/libcrypto-3-261e23882966c91c28b43ddfc72a46c6.dll +0 -0
- fizzpy.libs/libsodium-f1b56eb55a86edee37a2614c8bd38272.dll +0 -0
- fizzpy.libs/msvcp140-a4c2229bdc2a2a630acdc095b4d86008.dll +0 -0
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())
|