Skip to main content

End-to-end encryption for HTTP APIs using RFC 9180 HPKE

Project description

hpke-http

End-to-end encryption for HTTP APIs.

uv add git+https://github.com/duale-ai/hpke-http

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 - SSE counter prevents replay attacks
  • RFC 9180 compliant - Auditable, interoperable standard

Quick Start

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,
    # compress=True,  # Optional: Zstd compression for SSE responses
    # max_sse_event_size=128 * 1024 * 1024,  # Optional: 128MB for large payloads
)

@app.post("/chat")
async def chat(request: Request):
    data = await request.json()  # Decrypted by middleware

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

    # Just use StreamingResponse - encryption is automatic!
    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,    # Optional: Zstd compression for requests
) as session:
    resp = await session.post("/chat", json={"prompt": "Hello"})
    async for chunk in session.iter_sse(resp):
        # bytes - matches native aiohttp response.content iteration
        print(chunk)  # b"event: progress\ndata: {...}\n\n"

Documentation

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

┌─────────┬─────────┬─────────┬─────────┬──────┬────────────┐
│ Ver(1B) │ KEM(2B) │ KDF(2B) │AEAD(2B) │Mode  │ Ciphertext │
│  0x01   │ 0x0020  │ 0x0001  │ 0x0003  │(1B)  │  + 16B tag │
└─────────┴─────────┴─────────┴─────────┴──────┴────────────┘
Header: X-HPKE-Enc: <base64url(32B ephemeral key)>
Overhead: 24 bytes (8B header + 16B tag)

SSE Event

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

How SSE Auto-Encryption Works

The middleware automatically encrypts SSE responses when both conditions are met:

  1. Request was encrypted - SCOPE_HPKE_CONTEXT exists in scope (from decrypted request)
  2. Response is SSE - Content-Type: text/event-stream header detected
# Middleware detection logic (simplified)
from hpke_http.constants import SCOPE_HPKE_CONTEXT

if scope.get(SCOPE_HPKE_CONTEXT) and b"text/event-stream" in content_type:
    # Auto-encrypt this streaming response

This is why media_type="text/event-stream" is required - it's the WHATWG-standard MIME type that signals "this is an SSE stream" to both browsers and the middleware.

Compression (Optional)

Zstd compression reduces bandwidth by 40-95% for JSON/text. Events <64B are sent uncompressed automatically.

HPKEMiddleware(..., compress=True)      # Server: compress SSE responses
HPKEClientSession(..., compress=True)   # Client: compress requests

Design

Choice Rationale
Compress-then-encrypt Encrypted data is incompressible
Zstd (RFC 8878) Best ratio/speed. Python 3.14 native.
64B threshold Smaller payloads skip compression
Per-chunk Each SSE event independent for streaming

Expected Savings

Data Type Savings
Large JSON (>1KB) 80-95%
Medium JSON (200B-1KB) 40-70%
HTML/XML 70-85%
Logs, code 40-60%
Small events (64-200B) 0-20%
Base64, random 0-25%

Wire Format

Plaintext:  encoding_id (1B) || compressed_data
Encoding:   0x00 = identity, 0x01 = zstd

Pitfalls

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

# SSE without proper content-type (won't auto-encrypt)
return StreamingResponse(gen())                                    # ❌ No encryption
return StreamingResponse(gen(), media_type="text/event-stream")    # ✅ Auto-encrypted

# Out-of-order decryption (multi-message context)
recipient.open(aad, ct2)  # ❌ Expects seq=0
recipient.open(aad, ct1)  # ✅ Decrypt in order

Limits

Resource Limit
HPKE messages/context 2^96-1
SSE events/session 2^32-1
SSE event buffer 64MB (configurable)
PSK minimum 32 bytes
Overhead 24 bytes

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

Security

Uses OpenSSL constant-time implementations via cryptography library.

Development

# Install with extras
uv add "hpke-http[fastapi] @ git+https://github.com/duale-ai/hpke-http"        # Server
uv add "hpke-http[aiohttp] @ git+https://github.com/duale-ai/hpke-http"        # Client
uv add "hpke-http[fastapi,zstd] @ git+https://github.com/duale-ai/hpke-http"   # Server + compression

# Local development
make install      # Setup venv
make test         # Run tests (1273 tests, 93% coverage)
make test-fuzz    # Property-based fuzz tests
make lint         # Format and lint

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-0.1.2.tar.gz (38.7 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-0.1.2-py3-none-any.whl (32.2 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for hpke_http-0.1.2.tar.gz
Algorithm Hash digest
SHA256 7c08ad26aec34107d54ef62d490298ccde1fd1751e782c6c2d03f6f39e745f26
MD5 d85ea0af8ba6850e60eefa6c8861a5c5
BLAKE2b-256 7c3f429306a3e1936b2a21b1917e6501616a073e49c248866732ce0ce3ef4944

See more details on using hashes here.

Provenance

The following attestation bundles were made for hpke_http-0.1.2.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-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: hpke_http-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 32.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-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 9a8add3e863276325dc213e985fcf5d2f1ea2792060ef226b19b63de12a47d8c
MD5 89699d4d48892bbc95c422953ae485fb
BLAKE2b-256 a02994ff7f6fd02e6644eccd5d7c4ec6c37e0e2f2157ac3cbc82768559f8a767

See more details on using hashes here.

Provenance

The following attestation bundles were made for hpke_http-0.1.2-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