Skip to content

Xevion/fizz-py

Repository files navigation

fizzpy

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"])  # X25519MLKEM768

TLS 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 ClientHello in Wireshark

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.

Install

pip install fizzpy

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

Using fizzpy as your TLS layer

This is the point of the project: your existing HTTP stack, with a post-quantum Fizz handshake underneath.

requests / urllib3

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"])  # 4588

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

httpx

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.

Reaching TLS 1.2-only hosts

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))        # httpx

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

Any socket: wrap_socket

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

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

The built-in client

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

Choosing the key exchange

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

Custom ClientHello extensions

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 X25519MLKEM768

The extension is sent verbatim in the ClientHello — see it on the wire with examples/pq_marker.py and the screenshot above.

Certificate verification

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)

Status

What works today:

  • Pluggable TLS under requests/urllib3 via FizzAdapter, under httpx via FizzHTTPTransport, and under any client that accepts a socket via wrap_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, or ssl.SSLCertVerificationError from wrap_socket — with messages free of C++ internals, so existing except blocks work unchanged
  • Read/write deadlines: settimeout (and the timeout= of requests/httpx) bound each operation, so a stalled peer raises socket.timeout (→ ReadTimeout) instead of hanging
  • Thread-safe under pooling: every connection is driven on one shared event loop, so a mounted FizzAdapter can be used from many threads concurrently
  • Plain CONNECT proxies work transparently — the HTTP client performs the CONNECT and 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 ssl fallback with fallback=True to 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_socket primitive 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 CONNECT proxies tunnelling to an HTTPS origin work fine

Building

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.

About

A python package providing bindings to Fizz, the TLS 1.3 C++ library

Topics

Resources

License

Stars

Watchers

Forks

Contributors