Post-quantum TLS 1.3 for Python — plug it under the HTTP client you already use, or use the small built-in client. Built on Facebook's Fizz (C++) via pybind11.
By default fizzpy offers a post-quantum key exchange: the standardized hybrid
X25519MLKEM768 group (codepoint 4588). A request negotiates a hybrid ML-KEM
handshake against servers that support it (Cloudflare, Google) and falls back to
classical X25519 against those that don't.
The idea: keep your HTTP client — swap only the TLS. Mount fizzpy on a
requests.Session and requests keeps doing everything it does well (cookies,
redirects, retries, multipart, connection pooling); fizzpy just performs the
handshake underneath, post-quantum by default.
import requests
from fizzpy.contrib.requests import FizzAdapter
session = requests.Session()
session.mount("https://", FizzAdapter())
r = session.get("https://www.cloudflare.com")
print(r.status_code) # 200
# Peek at the negotiated handshake (stream=True keeps the connection attached):
with session.get("https://www.cloudflare.com", stream=True) as r:
print(r.raw.connection.sock.tls["group"]) # X25519MLKEM768TLS 1.3 only, HTTP/1.1 only — a Fizz constraint, not a temporary gap. fizzpy cannot natively talk to TLS 1.2-only servers (an opt-in stdlib fallback can). See Status.
A fizzpy request captured in Wireshark: the TLS 1.3 ClientHello offers the
post-quantum X25519MLKEM768 group (0x11ec), and a custom marker extension
(0xFE5A, see below) makes it unmistakably
ours — generated by examples/pq_marker.py.
pip install fizzpyThe Linux wheels are self-contained: folly, Fizz, and liboqs are statically
linked into the extension, so a fresh pip install has working post-quantum
TLS with no system packages to add. certifi (pulled in automatically) supplies
the default trust store.
Wheels are produced by CI (manylinux_2_28, x86_64, CPython 3.10–3.14); if one
isn't available for your platform yet, build from source — see
BUILDING.md.
This is the point of the project: your existing HTTP stack, with a post-quantum Fizz handshake underneath.
FizzAdapter is a standard requests transport adapter (install the extra:
pip install fizzpy[requests]). Mount it and every HTTPS request the session
makes handshakes through Fizz — everything above TLS (cookies, redirects,
retries, pooling, proxies) is still plain requests.
import requests
from fizzpy.contrib.requests import FizzAdapter
session = requests.Session()
session.mount("https://", FizzAdapter())
r = session.get("https://www.cloudflare.com")
print(r.status_code) # 200
# stream=True keeps urllib3's connection attached so the TlsSocket is reachable:
with session.get("https://www.cloudflare.com", stream=True) as r:
print(r.raw.connection.sock.tls["group_code"]) # 4588Shape the handshake with a TlsConfig; the usual HTTPAdapter
keyword arguments (max_retries, pool_connections, …) still work:
from fizzpy import TlsConfig, NamedGroup
from fizzpy.contrib.requests import FizzAdapter
pq_only = TlsConfig(groups=[NamedGroup.x25519_mlkem768])
session.mount("https://", FizzAdapter(pq_only, max_retries=3))Verification follows requests' own verify= argument: verify="/path/ca.pem"
trusts a specific CA, verify=False disables it. Hostname checking happens
inside the Fizz handshake.
FizzHTTPTransport is an httpx transport (install the extra:
pip install fizzpy[httpx]). Hand it to httpx.Client(transport=...) and httpx
keeps cookies, redirects, pooling, and its request/response models while Fizz
does the handshake.
import httpx
from fizzpy import TlsConfig, NamedGroup
from fizzpy.contrib.httpx import FizzHTTPTransport
client = httpx.Client(transport=FizzHTTPTransport())
r = client.get("https://www.cloudflare.com")
print(r.status_code) # 200
# The negotiated handshake is on the TlsSocket; stream to keep it reachable:
with client.stream("GET", "https://www.cloudflare.com") as r:
sock = r.extensions["network_stream"].get_extra_info("socket")
print(sock.tls["group_code"]) # 4588
# Shape the handshake with a TlsConfig:
client = httpx.Client(transport=FizzHTTPTransport(
TlsConfig(groups=[NamedGroup.x25519_mlkem768])
))Unlike the requests adapter, TLS is configured via TlsConfig, not httpx's
verify=. httpx has no separate ssl_context parameter, so fizzpy must occupy
the verify= slot to install itself — meaning httpx's own verify=/cert=
can't also be honored. FizzHTTPTransport rejects them (and http2=True, which
it can't support) with a clear error rather than silently ignoring them.
Fizz speaks TLS 1.3 only, so by default a request to a TLS 1.2-only server fails
loudly (a post-quantum handshake is the whole point — silently downgrading would
defeat it). When you'd rather reach such hosts anyway, opt into a fallback: on a
version-mismatch failure, fizzpy retries the connection over the standard library
ssl module — a classical (non post-quantum) handshake.
session.mount("https://", FizzAdapter(fallback=True)) # requests
httpx.Client(transport=FizzHTTPTransport(fallback=True)) # httpxThe fallback fires only on a protocol_version alert. A certificate or
hostname failure still raises — a bad cert is never downgraded into a successful
classical handshake. The retry honours the same TlsConfig verification (CA
bundle, verify=False) the Fizz handshake would have used.
The adapter is built on a single primitive — hand fizzpy an already-connected
socket and get back a blocking, ssl.SSLSocket-shaped object. This is how you
plug Fizz under anything that lets you supply your own TLS:
import socket, fizzpy
tcp = socket.create_connection(("www.cloudflare.com", 443))
tls = fizzpy.wrap_socket(tcp, "www.cloudflare.com")
tls.sendall(b"GET / HTTP/1.1\r\nHost: www.cloudflare.com\r\n"
b"Connection: close\r\n\r\n")
print(tls.tls["group"]) # X25519MLKEM768
print(tls.recv(64))The socket's file descriptor is duplicated and handed to Fizz's event loop, so
the caller keeps full control of the original tcp socket (close it whenever;
the TLS connection lives on through the dup). The returned TlsSocket supports
recv/recv_into, send/sendall, makefile, settimeout, fileno, and
close — the slice of the socket API http.client/urllib3 drive.
TlsConfig is the frozen set of handshake knobs shared by wrap_socket and the
contrib adapters:
from fizzpy import TlsConfig, NamedGroup, Extension
config = TlsConfig(
verify=True, # chain + hostname verification
cafile="/path/to/ca.pem", # default: certifi bundle
alpn=["http/1.1"],
groups=[NamedGroup.x25519_mlkem768, NamedGroup.x25519],
extensions=[Extension(0xFE5A, b"hi")], # custom ClientHello extensions
timeout=30.0,
)fizzpy also ships a small standalone client, handy for scripts and for seeing the negotiated parameters directly. It drives the same C++ core.
import fizzpy
r = fizzpy.get("https://www.cloudflare.com")
print(r.status_code) # 200
print(r.headers["content-type"]) # text/html; charset=UTF-8
print(r.text[:64])
# The negotiated TLS 1.3 parameters are attached to every response:
print(r.tls)
# {'version': 'TLSv1.3', 'cipher': 'TLS_AES_128_GCM_SHA256',
# 'group': 'X25519MLKEM768', 'group_code': 4588,
# 'alpn': 'http/1.1', 'sni': 'www.cloudflare.com', 'peer_cert': '...'}Asynchronous (the same core, resolved on the running event loop):
import asyncio
from fizzpy.aio import AsyncClient
async def main():
async with AsyncClient() as client:
r = await client.get("https://www.google.com")
print(r.status_code, r.tls["group"], r.tls["group_code"])
asyncio.run(main())The default offers [x25519_mlkem768, x25519], mirroring how Chrome and Firefox
send both key shares. Override it per client:
import fizzpy
from fizzpy import NamedGroup
# Force post-quantum only (handshake fails if the server lacks ML-KEM):
pq = fizzpy.Client(groups=[NamedGroup.x25519_mlkem768])
# Classical only:
classical = fizzpy.Client(groups=[NamedGroup.x25519])fizzpy surfaces Fizz's low-level extension hook, so you can append arbitrary
extensions to the TLS 1.3 ClientHello. Each is an opaque (type, data) pair;
fizzpy rejects types it manages itself (key_share, supported_versions, …) and
out-of-range or duplicate types.
import fizzpy
client = fizzpy.Client(
extensions=[fizzpy.Extension(0xFE5A, b"hello from fizzpy")],
)
r = client.get("https://blog.cloudflare.com")
print(r.tls["group"]) # still negotiates X25519MLKEM768The extension is sent verbatim in the ClientHello — see it on the wire with
examples/pq_marker.py and the screenshot above.
Certificate chains are verified against the certifi CA bundle and the hostname is checked against the certificate's SAN by default. Point at a custom CA, or disable verification entirely:
fizzpy.Client(cafile="/path/to/ca.pem") # trust a specific CA
fizzpy.Client(verify=False) # accept any certificate (insecure)What works today:
- Pluggable TLS under
requests/urllib3 viaFizzAdapter, underhttpxviaFizzHTTPTransport, and under any client that accepts a socket viawrap_socket - TLS 1.3 handshake with classical or post-quantum (ML-KEM) key exchange
- Built-in client:
GET/POST/HEAD/PUT/DELETE, sync and async - HTTP/1.1 response framing (content-length, chunked, gzip/deflate)
- Connection keep-alive with a per-host pool; automatic redirect following
- Chain + hostname certificate verification, certifi default + custom CA trust
- TLS failures raise the client's native SSL error type —
requests.exceptions.SSLError,httpx.ConnectError, orssl.SSLCertVerificationErrorfromwrap_socket— with messages free of C++ internals, so existingexceptblocks work unchanged - Read/write deadlines:
settimeout(and thetimeout=of requests/httpx) bound each operation, so a stalled peer raisessocket.timeout(→ReadTimeout) instead of hanging - Thread-safe under pooling: every connection is driven on one shared event loop,
so a mounted
FizzAdaptercan be used from many threads concurrently - Plain
CONNECTproxies work transparently — the HTTP client performs theCONNECTand fizzpy does the TLS handshake over the tunnel, so HTTPS-through-proxy needs nothing extra (an HTTPS proxy itself is unsupported, see below)
Current limitations:
- TLS 1.3 only (a Fizz constraint) — it cannot talk to TLS 1.2-only servers.
By default such a request fails loudly; opt into a stdlib
sslfallback withfallback=Trueto reach those hosts over a classical handshake instead (see Reaching TLS 1.2-only hosts). - HTTP/1.1 only (no HTTP/2)
- Packaged adapters: requests/urllib3 and httpx (sync). An async httpx transport
and an aiohttp connector can be built on the same
wrap_socketprimitive but aren't shipped yet - No client certificates (mutual TLS) yet; CA trust is a single bundle file
(no
capath/cadata) - HTTPS proxies (TLS to the proxy itself) aren't supported — fizzpy would try a
post-quantum handshake against the proxy. Plain
CONNECTproxies tunnelling to an HTTPS origin work fine
The post-quantum handshake requires a Fizz built against
liboqs. The CI wheels build the
whole folly + Fizz + liboqs tree from source inside manylinux_2_28; see
BUILDING.md for that recipe and for local development builds.
