Metadata-Version: 2.4
Name: hpke_http
Version: 1.1.0
Summary: End-to-end encryption for HTTP APIs using RFC 9180 HPKE. Drop-in middleware for FastAPI, aiohttp, and httpx.
Author: Duale AI
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/dualeai/hpke-http
Project-URL: Documentation, https://github.com/dualeai/hpke-http#readme
Project-URL: Repository, https://github.com/dualeai/hpke-http.git
Project-URL: Changelog, https://github.com/dualeai/hpke-http/releases
Project-URL: Issues, https://github.com/dualeai/hpke-http/issues
Keywords: hpke,encryption,rfc9180,cryptography,http,e2e,chacha20,x25519,fastapi,aiohttp,httpx,sse,streaming
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Security :: Cryptography
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: cryptography~=46.0
Provides-Extra: dev
Requires-Dist: granian~=2.0; extra == "dev"
Requires-Dist: hypothesis~=6.148; extra == "dev"
Requires-Dist: py-spy~=0.4; extra == "dev"
Requires-Dist: pyright~=1.1; extra == "dev"
Requires-Dist: pytest-asyncio~=0.24; extra == "dev"
Requires-Dist: pytest-cov~=7.0; extra == "dev"
Requires-Dist: pytest-mock~=3.14; extra == "dev"
Requires-Dist: pytest-xdist[psutil]~=3.8; extra == "dev"
Requires-Dist: pytest~=8.3; extra == "dev"
Requires-Dist: ruff~=0.7; extra == "dev"
Requires-Dist: scipy~=1.14; extra == "dev"
Requires-Dist: twine~=6.1; extra == "dev"
Requires-Dist: typing_extensions~=4.12; extra == "dev"
Requires-Dist: vulture~=2.14; extra == "dev"
Provides-Extra: testing
Requires-Dist: pytest-asyncio~=0.24; extra == "testing"
Requires-Dist: pytest~=8.3; extra == "testing"
Provides-Extra: fastapi
Requires-Dist: starlette~=0.50; extra == "fastapi"
Provides-Extra: aiohttp
Requires-Dist: aiohttp~=3.13; extra == "aiohttp"
Provides-Extra: httpx
Requires-Dist: httpx~=0.28; extra == "httpx"
Provides-Extra: zstd
Requires-Dist: backports.zstd>=1.0; python_version < "3.14" and extra == "zstd"

# hpke-http

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

[![CI](https://github.com/dualeai/hpke-http/actions/workflows/test.yml/badge.svg)](https://github.com/dualeai/hpke-http/actions/workflows/test.yml)
[![PyPI](https://img.shields.io/pypi/v/hpke-http)](https://pypi.org/project/hpke-http/)
[![Downloads](https://img.shields.io/pypi/dm/hpke-http)](https://pypi.org/project/hpke-http/)
[![Python](https://img.shields.io/pypi/pyversions/hpke-http)](https://pypi.org/project/hpke-http/)
[![License](https://img.shields.io/pypi/l/hpke-http)](https://opensource.org/licenses/Apache-2.0)

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

```bash
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)

```python
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)

```python
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)

```python
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](https://datatracker.ietf.org/doc/rfc9180/)
- [RFC 7748 - X25519](https://datatracker.ietf.org/doc/rfc7748/)
- [RFC 5869 - HKDF](https://datatracker.ietf.org/doc/rfc5869/)
- [RFC 8439 - ChaCha20-Poly1305](https://datatracker.ietf.org/doc/rfc8439/)
- [RFC 8878 - Zstandard](https://datatracker.ietf.org/doc/rfc8878/) (preferred compression)
- [RFC 1952 - Gzip](https://datatracker.ietf.org/doc/rfc1952/) (fallback compression, stdlib)
- [RFC 9110 - HTTP Semantics](https://datatracker.ietf.org/doc/rfc9110/) (Accept-Encoding negotiation)

## Security

Uses OpenSSL constant-time implementations via `cryptography` library.

- [Security Policy](./SECURITY.md) - Vulnerability reporting
- [SBOM](https://github.com/dualeai/hpke-http/releases) - CycloneDX attached to releases

## Contributing

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

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

## License

[Apache-2.0](https://opensource.org/licenses/Apache-2.0)

---

<details>
<summary>Technical Details</summary>

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

```text
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

```text
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.

```python
# 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

```python
# 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

```python
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)
```

</details>
