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.
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
- RFC 9180 - HPKE
- RFC 7748 - X25519
- RFC 5869 - HKDF
- RFC 8439 - ChaCha20-Poly1305
- RFC 8878 - Zstandard (preferred compression)
- RFC 1952 - Gzip (fallback compression, stdlib)
- RFC 9110 - HTTP Semantics (Accept-Encoding negotiation)
Security
Uses OpenSSL constant-time implementations via cryptography library.
- Security Policy - Vulnerability reporting
- SBOM - CycloneDX attached to releases
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
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 (requiresmedia_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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3dd951e7baac1f0210b5705d4a3588fb6d0074aeecf231b4b5fff28aaa9195f
|
|
| MD5 |
ab5f5ba13a075ad2218578e8142a6dc3
|
|
| BLAKE2b-256 |
d39219045dedb3780fa1b051d6ffe18be0d868edeaa677b01a07b96a4149469a
|
Provenance
The following attestation bundles were made for hpke_http-1.1.0.tar.gz:
Publisher:
release.yml on dualeai/hpke-http
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hpke_http-1.1.0.tar.gz -
Subject digest:
a3dd951e7baac1f0210b5705d4a3588fb6d0074aeecf231b4b5fff28aaa9195f - Sigstore transparency entry: 810179373
- Sigstore integration time:
-
Permalink:
dualeai/hpke-http@56e8f94ddc7e1a959ca960bedcaf1107ea219032 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/dualeai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@56e8f94ddc7e1a959ca960bedcaf1107ea219032 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a05a9aa2c95e0cb38e56509912bdf4eae407bc995dc2f5ac6eebf9431fb806b3
|
|
| MD5 |
39298f43c41a08fd4bf2feda708549c0
|
|
| BLAKE2b-256 |
6363e37b8f7c1d29820e136f3c04038a783591cea111967cd012519ccf1594f4
|
Provenance
The following attestation bundles were made for hpke_http-1.1.0-py3-none-any.whl:
Publisher:
release.yml on dualeai/hpke-http
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hpke_http-1.1.0-py3-none-any.whl -
Subject digest:
a05a9aa2c95e0cb38e56509912bdf4eae407bc995dc2f5ac6eebf9431fb806b3 - Sigstore transparency entry: 810179383
- Sigstore integration time:
-
Permalink:
dualeai/hpke-http@56e8f94ddc7e1a959ca960bedcaf1107ea219032 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/dualeai
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@56e8f94ddc7e1a959ca960bedcaf1107ea219032 -
Trigger Event:
release
-
Statement type: