Skip to main content

End-to-end encryption for HTTP APIs using RFC 9180 HPKE. Drop-in middleware for FastAPI, aiohttp, and httpx.

Project description

hpke-http

End-to-end encryption for HTTP APIs using RFC 9180 HPKE. Drop-in middleware for FastAPI, aiohttp, and httpx.

CI PyPI Downloads Python License

Highlights

  • Transparent - Drop-in middleware, no application code changes
  • E2E encryption - Protects data even with TLS termination at CDN/LB
  • PSK binding - Each request cryptographically bound to API key
  • Replay protection - Counter-based nonces prevent replay attacks
  • RFC 9180 compliant - Auditable, interoperable standard

Installation

uv add "hpke-http[fastapi]"       # Server
uv add "hpke-http[aiohttp]"       # Client (aiohttp)
uv add "hpke-http[httpx]"         # Client (httpx)
uv add "hpke-http[fastapi,zstd]"  # + zstd compression (gzip fallback is stdlib)

Quick Start

Both standard JSON requests and SSE streaming are transparently encrypted.

Server (FastAPI)

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from hpke_http.middleware.fastapi import HPKEMiddleware
from hpke_http.constants import KemId

app = FastAPI()

async def resolve_psk(scope: dict) -> tuple[bytes, bytes]:
    api_key = dict(scope["headers"]).get(b"authorization", b"").decode()
    return (api_key.encode(), (await lookup_tenant(api_key)).encode())

app.add_middleware(
    HPKEMiddleware,
    private_keys={KemId.DHKEM_X25519_HKDF_SHA256: private_key},
    psk_resolver=resolve_psk,
)

# Standard JSON endpoint - encryption is automatic
@app.post("/users")
async def create_user(request: Request):
    data = await request.json()  # Decrypted automatically
    return {"id": 123, "name": data["name"]}  # Encrypted automatically

# SSE streaming endpoint - encryption is automatic
@app.post("/chat")
async def chat(request: Request):
    data = await request.json()  # Decrypted automatically

    async def generate():
        yield b"event: progress\ndata: {\"step\": 1}\n\n"
        yield b"event: complete\ndata: {\"result\": \"done\"}\n\n"

    return StreamingResponse(generate(), media_type="text/event-stream")

Client (aiohttp)

from hpke_http.middleware.aiohttp import HPKEClientSession

async with HPKEClientSession(
    base_url="https://api.example.com",
    psk=api_key,        # >= 32 bytes
    psk_id=tenant_id,
    # compress=True,           # Compression (zstd preferred, gzip fallback)
    # require_encryption=True, # Raise if server responds unencrypted
    # release_encrypted=True,  # Free encrypted bytes after decryption (saves memory)
) as session:
    # Standard JSON request - encryption is automatic
    async with session.post("/users", json={"name": "Alice"}) as resp:
        user = await resp.json()  # Decrypted automatically
        print(user)  # {"id": 123, "name": "Alice"}

    # SSE streaming request - encryption is automatic
    async with session.post("/chat", json={"prompt": "Hello"}) as resp:
        async for chunk in session.iter_sse(resp):
            print(chunk)  # b"event: progress\ndata: {...}\n\n"

Client (httpx)

from hpke_http.middleware.httpx import HPKEAsyncClient

async with HPKEAsyncClient(
    base_url="https://api.example.com",
    psk=api_key,        # >= 32 bytes
    psk_id=tenant_id,
    # compress=True,           # Compression (zstd preferred, gzip fallback)
    # require_encryption=True, # Raise if server responds unencrypted
    # release_encrypted=True,  # Free encrypted bytes after decryption (saves memory)
) as client:
    # Standard JSON request - encryption is automatic
    resp = await client.post("/users", json={"name": "Alice"})
    user = resp.json()  # Decrypted automatically
    print(user)  # {"id": 123, "name": "Alice"}

    # SSE streaming request - encryption is automatic
    resp = await client.post("/chat", json={"prompt": "Hello"})
    async for chunk in client.iter_sse(resp):
        print(chunk)  # b"event: progress\ndata: {...}\n\n"

Documentation

Security

Uses OpenSSL constant-time implementations via cryptography library.

Contributing

Contributions welcome! Please open an issue first to discuss changes.

make install      # Setup venv
make test         # Run tests
make lint         # Format and lint

License

Apache-2.0


Technical Details

Cipher Suite

Component Algorithm ID
KEM DHKEM(X25519, HKDF-SHA256) 0x0020
KDF HKDF-SHA256 0x0001
AEAD ChaCha20-Poly1305 0x0003
Mode PSK 0x01

Wire Format

Request/Response (Chunked Binary)

Headers:
  X-HPKE-Enc: <base64url(32B ephemeral key)>
  X-HPKE-Stream: <base64url(4B session salt)>

Body (repeating chunks):
┌───────────┬────────────┬─────────────────────────────────┐
│ Length(4B)│ Counter(4B)│ Ciphertext (N + 16B tag)        │
│ big-end   │ big-end    │ encrypted: encoding_id || data  │
└───────────┴────────────┴─────────────────────────────────┘
Overhead: 24B/chunk (4B length + 4B counter + 16B tag)

SSE Event

event: enc
data: <base64(counter_be32 || ciphertext)>
Decrypted: raw SSE chunk (e.g., "event: progress\ndata: {...}\n\n")

Uses standard base64 (not base64url) - SSE data fields allow +/= characters.

Auto-Encryption

The middleware automatically encrypts all responses when the request was encrypted:

  • Standard responses (JSON, HTML, etc.) - Uses chunked binary format (RawFormat)
  • SSE responses - Uses base64-encoded SSE events (SSEFormat)

Response type is detected via Content-Type header:

  • text/event-stream → SSE format (requires media_type="text/event-stream")
  • Everything else → Binary chunked format

Compression (Optional)

Zstd (RFC 8878) reduces bandwidth by 40-95% for JSON/text. Gzip (RFC 1952) is used as fallback when zstd is unavailable.

Auto-negotiation: Clients with compress=True automatically detect server capabilities via the Accept-Encoding header in discovery response. Priority: zstd > gzip > identity.

# Client auto-negotiates best available encoding
async with HPKEClientSession(base_url=url, psk=key, compress=True) as client:
    # Check server capabilities (after first request triggers discovery)
    print(client.server_supports_zstd)  # True or False
    print(client.server_supports_gzip)  # True (always, stdlib)

Server config: Enable with compress=True. Server advertises supported encodings (identity, gzip, zstd or identity, gzip if zstd unavailable).

Request compression: Client compresses body before encryption when compress=True, using best mutually-supported encoding. Payloads < 64 bytes skip compression.

Response compression: Server compresses response chunks (both SSE and standard) when compress=True. Payloads < 64 bytes skip compression.

Pitfalls

# PSK too short
HPKEClientSession(psk=b"short")                 # InvalidPSKError
HPKEClientSession(psk=secrets.token_bytes(32))  # >= 32 bytes

# SSE missing content-type (won't use SSE format)
return StreamingResponse(gen())                                  # Binary format (wrong for SSE)
return StreamingResponse(gen(), media_type="text/event-stream")  # SSE format (correct)

# Standard responses work automatically - no special handling needed
return {"data": "value"}  # Auto-encrypted as binary chunks

Limits

Resource Limit Applies to
HPKE messages/context 2^96-1 All
Chunks/session 2^32-1 All
PSK minimum 32 bytes All
Chunk size 64KB All
Binary chunk overhead 24B (length + counter + tag) Requests & standard responses
SSE event buffer 64MB (configurable) SSE only

Note: SSE is text-only (UTF-8). Binary data must be base64-encoded (+33% overhead).

Low-Level API

from hpke_http.hpke import seal_psk, open_psk

enc, ct = seal_psk(pk_r, b"info", psk, psk_id, b"aad", b"plaintext")
pt = open_psk(enc, sk_r, b"info", psk, psk_id, b"aad", ct)

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

hpke_http-1.1.0.tar.gz (94.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

hpke_http-1.1.0-py3-none-any.whl (59.2 kB view details)

Uploaded Python 3

File details

Details for the file hpke_http-1.1.0.tar.gz.

File metadata

  • Download URL: hpke_http-1.1.0.tar.gz
  • Upload date:
  • Size: 94.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for hpke_http-1.1.0.tar.gz
Algorithm Hash digest
SHA256 a3dd951e7baac1f0210b5705d4a3588fb6d0074aeecf231b4b5fff28aaa9195f
MD5 ab5f5ba13a075ad2218578e8142a6dc3
BLAKE2b-256 d39219045dedb3780fa1b051d6ffe18be0d868edeaa677b01a07b96a4149469a

See more details on using hashes here.

Provenance

The following attestation bundles were made for hpke_http-1.1.0.tar.gz:

Publisher: release.yml on dualeai/hpke-http

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file hpke_http-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: hpke_http-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 59.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for hpke_http-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a05a9aa2c95e0cb38e56509912bdf4eae407bc995dc2f5ac6eebf9431fb806b3
MD5 39298f43c41a08fd4bf2feda708549c0
BLAKE2b-256 6363e37b8f7c1d29820e136f3c04038a783591cea111967cd012519ccf1594f4

See more details on using hashes here.

Provenance

The following attestation bundles were made for hpke_http-1.1.0-py3-none-any.whl:

Publisher: release.yml on dualeai/hpke-http

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page