hpke-http 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hpke_http-0.1.2/PKG-INFO +266 -0
- hpke_http-0.1.2/README.md +215 -0
- hpke_http-0.1.2/pyproject.toml +174 -0
- hpke_http-0.1.2/setup.cfg +4 -0
- hpke_http-0.1.2/src/hpke_http/__init__.py +52 -0
- hpke_http-0.1.2/src/hpke_http/constants.py +232 -0
- hpke_http-0.1.2/src/hpke_http/envelope.py +167 -0
- hpke_http-0.1.2/src/hpke_http/exceptions.py +80 -0
- hpke_http-0.1.2/src/hpke_http/headers.py +48 -0
- hpke_http-0.1.2/src/hpke_http/hpke.py +375 -0
- hpke_http-0.1.2/src/hpke_http/middleware/__init__.py +9 -0
- hpke_http-0.1.2/src/hpke_http/middleware/aiohttp.py +392 -0
- hpke_http-0.1.2/src/hpke_http/middleware/fastapi.py +482 -0
- hpke_http-0.1.2/src/hpke_http/primitives/__init__.py +19 -0
- hpke_http-0.1.2/src/hpke_http/primitives/aead.py +175 -0
- hpke_http-0.1.2/src/hpke_http/primitives/kdf.py +116 -0
- hpke_http-0.1.2/src/hpke_http/primitives/kem.py +211 -0
- hpke_http-0.1.2/src/hpke_http/py.typed +0 -0
- hpke_http-0.1.2/src/hpke_http/streaming.py +329 -0
- hpke_http-0.1.2/src/hpke_http.egg-info/PKG-INFO +266 -0
- hpke_http-0.1.2/src/hpke_http.egg-info/SOURCES.txt +25 -0
- hpke_http-0.1.2/src/hpke_http.egg-info/dependency_links.txt +1 -0
- hpke_http-0.1.2/src/hpke_http.egg-info/requires.txt +30 -0
- hpke_http-0.1.2/src/hpke_http.egg-info/top_level.txt +1 -0
- hpke_http-0.1.2/tests/test_fuzz.py +406 -0
- hpke_http-0.1.2/tests/test_malformed_requests.py +146 -0
- hpke_http-0.1.2/tests/test_middleware.py +422 -0
hpke_http-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hpke_http
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: End-to-end encryption for HTTP APIs using RFC 9180 HPKE
|
|
5
|
+
Author: Duale AI
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/dualeai/hpke-http
|
|
8
|
+
Project-URL: Documentation, https://github.com/dualeai/hpke-http#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/dualeai/hpke-http.git
|
|
10
|
+
Project-URL: Changelog, https://github.com/dualeai/hpke-http/releases
|
|
11
|
+
Project-URL: Issues, https://github.com/dualeai/hpke-http/issues
|
|
12
|
+
Keywords: hpke,encryption,rfc9180,cryptography,http,e2e,chacha20,x25519,fastapi,aiohttp,sse,streaming
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
23
|
+
Classifier: Topic :: Security :: Cryptography
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
Requires-Dist: cryptography~=46.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: granian~=2.0; extra == "dev"
|
|
31
|
+
Requires-Dist: hypothesis~=6.148; extra == "dev"
|
|
32
|
+
Requires-Dist: pyright~=1.1; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest-asyncio~=0.24; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov~=7.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-mock~=3.14; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-xdist[psutil]~=3.8; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest~=8.3; extra == "dev"
|
|
38
|
+
Requires-Dist: ruff~=0.7; extra == "dev"
|
|
39
|
+
Requires-Dist: twine~=6.1; extra == "dev"
|
|
40
|
+
Requires-Dist: typing_extensions~=4.12; extra == "dev"
|
|
41
|
+
Requires-Dist: vulture~=2.14; extra == "dev"
|
|
42
|
+
Provides-Extra: testing
|
|
43
|
+
Requires-Dist: pytest-asyncio~=0.24; extra == "testing"
|
|
44
|
+
Requires-Dist: pytest~=8.3; extra == "testing"
|
|
45
|
+
Provides-Extra: fastapi
|
|
46
|
+
Requires-Dist: starlette~=0.50; extra == "fastapi"
|
|
47
|
+
Provides-Extra: aiohttp
|
|
48
|
+
Requires-Dist: aiohttp~=3.13; extra == "aiohttp"
|
|
49
|
+
Provides-Extra: zstd
|
|
50
|
+
Requires-Dist: backports.zstd>=1.0; python_version < "3.14" and extra == "zstd"
|
|
51
|
+
|
|
52
|
+
# hpke-http
|
|
53
|
+
|
|
54
|
+
End-to-end encryption for HTTP APIs.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
uv add git+https://github.com/duale-ai/hpke-http
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Highlights
|
|
61
|
+
|
|
62
|
+
- **Transparent** - Drop-in middleware, no application code changes
|
|
63
|
+
- **E2E encryption** - Protects data even with TLS termination at CDN/LB
|
|
64
|
+
- **PSK binding** - Each request cryptographically bound to API key
|
|
65
|
+
- **Replay protection** - SSE counter prevents replay attacks
|
|
66
|
+
- **RFC 9180 compliant** - Auditable, interoperable standard
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
### Server (FastAPI)
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from fastapi import FastAPI, Request
|
|
74
|
+
from fastapi.responses import StreamingResponse
|
|
75
|
+
from hpke_http.middleware.fastapi import HPKEMiddleware
|
|
76
|
+
from hpke_http.constants import KemId
|
|
77
|
+
|
|
78
|
+
app = FastAPI()
|
|
79
|
+
|
|
80
|
+
async def resolve_psk(scope: dict) -> tuple[bytes, bytes]:
|
|
81
|
+
api_key = dict(scope["headers"]).get(b"authorization", b"").decode()
|
|
82
|
+
return (api_key.encode(), (await lookup_tenant(api_key)).encode())
|
|
83
|
+
|
|
84
|
+
app.add_middleware(
|
|
85
|
+
HPKEMiddleware,
|
|
86
|
+
private_keys={KemId.DHKEM_X25519_HKDF_SHA256: private_key},
|
|
87
|
+
psk_resolver=resolve_psk,
|
|
88
|
+
# compress=True, # Optional: Zstd compression for SSE responses
|
|
89
|
+
# max_sse_event_size=128 * 1024 * 1024, # Optional: 128MB for large payloads
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@app.post("/chat")
|
|
93
|
+
async def chat(request: Request):
|
|
94
|
+
data = await request.json() # Decrypted by middleware
|
|
95
|
+
|
|
96
|
+
async def generate():
|
|
97
|
+
yield b"event: progress\ndata: {\"step\": 1}\n\n"
|
|
98
|
+
yield b"event: complete\ndata: {\"result\": \"done\"}\n\n"
|
|
99
|
+
|
|
100
|
+
# Just use StreamingResponse - encryption is automatic!
|
|
101
|
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Client (aiohttp)
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from hpke_http.middleware.aiohttp import HPKEClientSession
|
|
108
|
+
|
|
109
|
+
async with HPKEClientSession(
|
|
110
|
+
base_url="https://api.example.com",
|
|
111
|
+
psk=api_key, # >= 32 bytes
|
|
112
|
+
psk_id=tenant_id,
|
|
113
|
+
# compress=True, # Optional: Zstd compression for requests
|
|
114
|
+
) as session:
|
|
115
|
+
resp = await session.post("/chat", json={"prompt": "Hello"})
|
|
116
|
+
async for chunk in session.iter_sse(resp):
|
|
117
|
+
# bytes - matches native aiohttp response.content iteration
|
|
118
|
+
print(chunk) # b"event: progress\ndata: {...}\n\n"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Documentation
|
|
122
|
+
|
|
123
|
+
- [RFC 9180 - HPKE](https://datatracker.ietf.org/doc/rfc9180/)
|
|
124
|
+
- [RFC 7748 - X25519](https://datatracker.ietf.org/doc/rfc7748/)
|
|
125
|
+
- [RFC 5869 - HKDF](https://datatracker.ietf.org/doc/rfc5869/)
|
|
126
|
+
- [RFC 8439 - ChaCha20-Poly1305](https://datatracker.ietf.org/doc/rfc8439/)
|
|
127
|
+
- [RFC 8878 - Zstandard](https://datatracker.ietf.org/doc/rfc8878/) (optional compression)
|
|
128
|
+
|
|
129
|
+
## Cipher Suite
|
|
130
|
+
|
|
131
|
+
| Component | Algorithm | ID |
|
|
132
|
+
|-----------|-----------|------|
|
|
133
|
+
| KEM | DHKEM(X25519, HKDF-SHA256) | 0x0020 |
|
|
134
|
+
| KDF | HKDF-SHA256 | 0x0001 |
|
|
135
|
+
| AEAD | ChaCha20-Poly1305 | 0x0003 |
|
|
136
|
+
| Mode | PSK | 0x01 |
|
|
137
|
+
|
|
138
|
+
## Wire Format
|
|
139
|
+
|
|
140
|
+
### Request
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
┌─────────┬─────────┬─────────┬─────────┬──────┬────────────┐
|
|
144
|
+
│ Ver(1B) │ KEM(2B) │ KDF(2B) │AEAD(2B) │Mode │ Ciphertext │
|
|
145
|
+
│ 0x01 │ 0x0020 │ 0x0001 │ 0x0003 │(1B) │ + 16B tag │
|
|
146
|
+
└─────────┴─────────┴─────────┴─────────┴──────┴────────────┘
|
|
147
|
+
Header: X-HPKE-Enc: <base64url(32B ephemeral key)>
|
|
148
|
+
Overhead: 24 bytes (8B header + 16B tag)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### SSE Event
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
event: enc
|
|
155
|
+
data: <base64url(counter_be32 || ciphertext || tag)>
|
|
156
|
+
Decrypted: raw SSE chunk (e.g., "event: progress\ndata: {...}\n\n")
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## How SSE Auto-Encryption Works
|
|
160
|
+
|
|
161
|
+
The middleware automatically encrypts SSE responses when **both** conditions are met:
|
|
162
|
+
|
|
163
|
+
1. **Request was encrypted** - `SCOPE_HPKE_CONTEXT` exists in scope (from decrypted request)
|
|
164
|
+
2. **Response is SSE** - `Content-Type: text/event-stream` header detected
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
# Middleware detection logic (simplified)
|
|
168
|
+
from hpke_http.constants import SCOPE_HPKE_CONTEXT
|
|
169
|
+
|
|
170
|
+
if scope.get(SCOPE_HPKE_CONTEXT) and b"text/event-stream" in content_type:
|
|
171
|
+
# Auto-encrypt this streaming response
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
## Compression (Optional)
|
|
177
|
+
|
|
178
|
+
Zstd compression reduces bandwidth by **40-95%** for JSON/text. Events <64B are sent uncompressed automatically.
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
HPKEMiddleware(..., compress=True) # Server: compress SSE responses
|
|
182
|
+
HPKEClientSession(..., compress=True) # Client: compress requests
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Design
|
|
186
|
+
|
|
187
|
+
| Choice | Rationale |
|
|
188
|
+
|--------|-----------|
|
|
189
|
+
| **Compress-then-encrypt** | Encrypted data is incompressible |
|
|
190
|
+
| **Zstd (RFC 8878)** | Best ratio/speed. Python 3.14 native. |
|
|
191
|
+
| **64B threshold** | Smaller payloads skip compression |
|
|
192
|
+
| **Per-chunk** | Each SSE event independent for streaming |
|
|
193
|
+
|
|
194
|
+
### Expected Savings
|
|
195
|
+
|
|
196
|
+
| Data Type | Savings |
|
|
197
|
+
|-----------|---------|
|
|
198
|
+
| Large JSON (>1KB) | 80-95% |
|
|
199
|
+
| Medium JSON (200B-1KB) | 40-70% |
|
|
200
|
+
| HTML/XML | 70-85% |
|
|
201
|
+
| Logs, code | 40-60% |
|
|
202
|
+
| Small events (64-200B) | 0-20% |
|
|
203
|
+
| Base64, random | 0-25% |
|
|
204
|
+
|
|
205
|
+
### Wire Format
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
Plaintext: encoding_id (1B) || compressed_data
|
|
209
|
+
Encoding: 0x00 = identity, 0x01 = zstd
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Pitfalls
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
# PSK too short
|
|
216
|
+
HPKEClientSession(psk=b"short") # ❌ InvalidPSKError
|
|
217
|
+
HPKEClientSession(psk=secrets.token_bytes(32)) # ✅ >= 32 bytes
|
|
218
|
+
|
|
219
|
+
# SSE without proper content-type (won't auto-encrypt)
|
|
220
|
+
return StreamingResponse(gen()) # ❌ No encryption
|
|
221
|
+
return StreamingResponse(gen(), media_type="text/event-stream") # ✅ Auto-encrypted
|
|
222
|
+
|
|
223
|
+
# Out-of-order decryption (multi-message context)
|
|
224
|
+
recipient.open(aad, ct2) # ❌ Expects seq=0
|
|
225
|
+
recipient.open(aad, ct1) # ✅ Decrypt in order
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Limits
|
|
229
|
+
|
|
230
|
+
| Resource | Limit |
|
|
231
|
+
|----------|-------|
|
|
232
|
+
| HPKE messages/context | 2^96-1 |
|
|
233
|
+
| SSE events/session | 2^32-1 |
|
|
234
|
+
| SSE event buffer | 64MB (configurable) |
|
|
235
|
+
| PSK minimum | 32 bytes |
|
|
236
|
+
| Overhead | 24 bytes |
|
|
237
|
+
|
|
238
|
+
> **Note:** SSE is text-only (UTF-8). Binary data must be base64-encoded (+33% overhead).
|
|
239
|
+
|
|
240
|
+
## Security
|
|
241
|
+
|
|
242
|
+
Uses OpenSSL constant-time implementations via `cryptography` library.
|
|
243
|
+
|
|
244
|
+
## Development
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
# Install with extras
|
|
248
|
+
uv add "hpke-http[fastapi] @ git+https://github.com/duale-ai/hpke-http" # Server
|
|
249
|
+
uv add "hpke-http[aiohttp] @ git+https://github.com/duale-ai/hpke-http" # Client
|
|
250
|
+
uv add "hpke-http[fastapi,zstd] @ git+https://github.com/duale-ai/hpke-http" # Server + compression
|
|
251
|
+
|
|
252
|
+
# Local development
|
|
253
|
+
make install # Setup venv
|
|
254
|
+
make test # Run tests (1273 tests, 93% coverage)
|
|
255
|
+
make test-fuzz # Property-based fuzz tests
|
|
256
|
+
make lint # Format and lint
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### Low-Level API
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
from hpke_http.hpke import seal_psk, open_psk
|
|
263
|
+
|
|
264
|
+
enc, ct = seal_psk(pk_r, b"info", psk, psk_id, b"aad", b"plaintext")
|
|
265
|
+
pt = open_psk(enc, sk_r, b"info", psk, psk_id, b"aad", ct)
|
|
266
|
+
```
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# hpke-http
|
|
2
|
+
|
|
3
|
+
End-to-end encryption for HTTP APIs.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv add git+https://github.com/duale-ai/hpke-http
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Highlights
|
|
10
|
+
|
|
11
|
+
- **Transparent** - Drop-in middleware, no application code changes
|
|
12
|
+
- **E2E encryption** - Protects data even with TLS termination at CDN/LB
|
|
13
|
+
- **PSK binding** - Each request cryptographically bound to API key
|
|
14
|
+
- **Replay protection** - SSE counter prevents replay attacks
|
|
15
|
+
- **RFC 9180 compliant** - Auditable, interoperable standard
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
### Server (FastAPI)
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from fastapi import FastAPI, Request
|
|
23
|
+
from fastapi.responses import StreamingResponse
|
|
24
|
+
from hpke_http.middleware.fastapi import HPKEMiddleware
|
|
25
|
+
from hpke_http.constants import KemId
|
|
26
|
+
|
|
27
|
+
app = FastAPI()
|
|
28
|
+
|
|
29
|
+
async def resolve_psk(scope: dict) -> tuple[bytes, bytes]:
|
|
30
|
+
api_key = dict(scope["headers"]).get(b"authorization", b"").decode()
|
|
31
|
+
return (api_key.encode(), (await lookup_tenant(api_key)).encode())
|
|
32
|
+
|
|
33
|
+
app.add_middleware(
|
|
34
|
+
HPKEMiddleware,
|
|
35
|
+
private_keys={KemId.DHKEM_X25519_HKDF_SHA256: private_key},
|
|
36
|
+
psk_resolver=resolve_psk,
|
|
37
|
+
# compress=True, # Optional: Zstd compression for SSE responses
|
|
38
|
+
# max_sse_event_size=128 * 1024 * 1024, # Optional: 128MB for large payloads
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
@app.post("/chat")
|
|
42
|
+
async def chat(request: Request):
|
|
43
|
+
data = await request.json() # Decrypted by middleware
|
|
44
|
+
|
|
45
|
+
async def generate():
|
|
46
|
+
yield b"event: progress\ndata: {\"step\": 1}\n\n"
|
|
47
|
+
yield b"event: complete\ndata: {\"result\": \"done\"}\n\n"
|
|
48
|
+
|
|
49
|
+
# Just use StreamingResponse - encryption is automatic!
|
|
50
|
+
return StreamingResponse(generate(), media_type="text/event-stream")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Client (aiohttp)
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from hpke_http.middleware.aiohttp import HPKEClientSession
|
|
57
|
+
|
|
58
|
+
async with HPKEClientSession(
|
|
59
|
+
base_url="https://api.example.com",
|
|
60
|
+
psk=api_key, # >= 32 bytes
|
|
61
|
+
psk_id=tenant_id,
|
|
62
|
+
# compress=True, # Optional: Zstd compression for requests
|
|
63
|
+
) as session:
|
|
64
|
+
resp = await session.post("/chat", json={"prompt": "Hello"})
|
|
65
|
+
async for chunk in session.iter_sse(resp):
|
|
66
|
+
# bytes - matches native aiohttp response.content iteration
|
|
67
|
+
print(chunk) # b"event: progress\ndata: {...}\n\n"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Documentation
|
|
71
|
+
|
|
72
|
+
- [RFC 9180 - HPKE](https://datatracker.ietf.org/doc/rfc9180/)
|
|
73
|
+
- [RFC 7748 - X25519](https://datatracker.ietf.org/doc/rfc7748/)
|
|
74
|
+
- [RFC 5869 - HKDF](https://datatracker.ietf.org/doc/rfc5869/)
|
|
75
|
+
- [RFC 8439 - ChaCha20-Poly1305](https://datatracker.ietf.org/doc/rfc8439/)
|
|
76
|
+
- [RFC 8878 - Zstandard](https://datatracker.ietf.org/doc/rfc8878/) (optional compression)
|
|
77
|
+
|
|
78
|
+
## Cipher Suite
|
|
79
|
+
|
|
80
|
+
| Component | Algorithm | ID |
|
|
81
|
+
|-----------|-----------|------|
|
|
82
|
+
| KEM | DHKEM(X25519, HKDF-SHA256) | 0x0020 |
|
|
83
|
+
| KDF | HKDF-SHA256 | 0x0001 |
|
|
84
|
+
| AEAD | ChaCha20-Poly1305 | 0x0003 |
|
|
85
|
+
| Mode | PSK | 0x01 |
|
|
86
|
+
|
|
87
|
+
## Wire Format
|
|
88
|
+
|
|
89
|
+
### Request
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
┌─────────┬─────────┬─────────┬─────────┬──────┬────────────┐
|
|
93
|
+
│ Ver(1B) │ KEM(2B) │ KDF(2B) │AEAD(2B) │Mode │ Ciphertext │
|
|
94
|
+
│ 0x01 │ 0x0020 │ 0x0001 │ 0x0003 │(1B) │ + 16B tag │
|
|
95
|
+
└─────────┴─────────┴─────────┴─────────┴──────┴────────────┘
|
|
96
|
+
Header: X-HPKE-Enc: <base64url(32B ephemeral key)>
|
|
97
|
+
Overhead: 24 bytes (8B header + 16B tag)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### SSE Event
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
event: enc
|
|
104
|
+
data: <base64url(counter_be32 || ciphertext || tag)>
|
|
105
|
+
Decrypted: raw SSE chunk (e.g., "event: progress\ndata: {...}\n\n")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How SSE Auto-Encryption Works
|
|
109
|
+
|
|
110
|
+
The middleware automatically encrypts SSE responses when **both** conditions are met:
|
|
111
|
+
|
|
112
|
+
1. **Request was encrypted** - `SCOPE_HPKE_CONTEXT` exists in scope (from decrypted request)
|
|
113
|
+
2. **Response is SSE** - `Content-Type: text/event-stream` header detected
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
# Middleware detection logic (simplified)
|
|
117
|
+
from hpke_http.constants import SCOPE_HPKE_CONTEXT
|
|
118
|
+
|
|
119
|
+
if scope.get(SCOPE_HPKE_CONTEXT) and b"text/event-stream" in content_type:
|
|
120
|
+
# Auto-encrypt this streaming response
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
## Compression (Optional)
|
|
126
|
+
|
|
127
|
+
Zstd compression reduces bandwidth by **40-95%** for JSON/text. Events <64B are sent uncompressed automatically.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
HPKEMiddleware(..., compress=True) # Server: compress SSE responses
|
|
131
|
+
HPKEClientSession(..., compress=True) # Client: compress requests
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Design
|
|
135
|
+
|
|
136
|
+
| Choice | Rationale |
|
|
137
|
+
|--------|-----------|
|
|
138
|
+
| **Compress-then-encrypt** | Encrypted data is incompressible |
|
|
139
|
+
| **Zstd (RFC 8878)** | Best ratio/speed. Python 3.14 native. |
|
|
140
|
+
| **64B threshold** | Smaller payloads skip compression |
|
|
141
|
+
| **Per-chunk** | Each SSE event independent for streaming |
|
|
142
|
+
|
|
143
|
+
### Expected Savings
|
|
144
|
+
|
|
145
|
+
| Data Type | Savings |
|
|
146
|
+
|-----------|---------|
|
|
147
|
+
| Large JSON (>1KB) | 80-95% |
|
|
148
|
+
| Medium JSON (200B-1KB) | 40-70% |
|
|
149
|
+
| HTML/XML | 70-85% |
|
|
150
|
+
| Logs, code | 40-60% |
|
|
151
|
+
| Small events (64-200B) | 0-20% |
|
|
152
|
+
| Base64, random | 0-25% |
|
|
153
|
+
|
|
154
|
+
### Wire Format
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
Plaintext: encoding_id (1B) || compressed_data
|
|
158
|
+
Encoding: 0x00 = identity, 0x01 = zstd
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Pitfalls
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
# PSK too short
|
|
165
|
+
HPKEClientSession(psk=b"short") # ❌ InvalidPSKError
|
|
166
|
+
HPKEClientSession(psk=secrets.token_bytes(32)) # ✅ >= 32 bytes
|
|
167
|
+
|
|
168
|
+
# SSE without proper content-type (won't auto-encrypt)
|
|
169
|
+
return StreamingResponse(gen()) # ❌ No encryption
|
|
170
|
+
return StreamingResponse(gen(), media_type="text/event-stream") # ✅ Auto-encrypted
|
|
171
|
+
|
|
172
|
+
# Out-of-order decryption (multi-message context)
|
|
173
|
+
recipient.open(aad, ct2) # ❌ Expects seq=0
|
|
174
|
+
recipient.open(aad, ct1) # ✅ Decrypt in order
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Limits
|
|
178
|
+
|
|
179
|
+
| Resource | Limit |
|
|
180
|
+
|----------|-------|
|
|
181
|
+
| HPKE messages/context | 2^96-1 |
|
|
182
|
+
| SSE events/session | 2^32-1 |
|
|
183
|
+
| SSE event buffer | 64MB (configurable) |
|
|
184
|
+
| PSK minimum | 32 bytes |
|
|
185
|
+
| Overhead | 24 bytes |
|
|
186
|
+
|
|
187
|
+
> **Note:** SSE is text-only (UTF-8). Binary data must be base64-encoded (+33% overhead).
|
|
188
|
+
|
|
189
|
+
## Security
|
|
190
|
+
|
|
191
|
+
Uses OpenSSL constant-time implementations via `cryptography` library.
|
|
192
|
+
|
|
193
|
+
## Development
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# Install with extras
|
|
197
|
+
uv add "hpke-http[fastapi] @ git+https://github.com/duale-ai/hpke-http" # Server
|
|
198
|
+
uv add "hpke-http[aiohttp] @ git+https://github.com/duale-ai/hpke-http" # Client
|
|
199
|
+
uv add "hpke-http[fastapi,zstd] @ git+https://github.com/duale-ai/hpke-http" # Server + compression
|
|
200
|
+
|
|
201
|
+
# Local development
|
|
202
|
+
make install # Setup venv
|
|
203
|
+
make test # Run tests (1273 tests, 93% coverage)
|
|
204
|
+
make test-fuzz # Property-based fuzz tests
|
|
205
|
+
make lint # Format and lint
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Low-Level API
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from hpke_http.hpke import seal_psk, open_psk
|
|
212
|
+
|
|
213
|
+
enc, ct = seal_psk(pk_r, b"info", psk, psk_id, b"aad", b"plaintext")
|
|
214
|
+
pt = open_psk(enc, sk_r, b"info", psk, psk_id, b"aad", ct)
|
|
215
|
+
```
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hpke_http"
|
|
3
|
+
version = "0.1.2"
|
|
4
|
+
description = "End-to-end encryption for HTTP APIs using RFC 9180 HPKE"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "Duale AI"},
|
|
10
|
+
]
|
|
11
|
+
keywords = [
|
|
12
|
+
"hpke",
|
|
13
|
+
"encryption",
|
|
14
|
+
"rfc9180",
|
|
15
|
+
"cryptography",
|
|
16
|
+
"http",
|
|
17
|
+
"e2e",
|
|
18
|
+
"chacha20",
|
|
19
|
+
"x25519",
|
|
20
|
+
"fastapi",
|
|
21
|
+
"aiohttp",
|
|
22
|
+
"sse",
|
|
23
|
+
"streaming",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Intended Audience :: Developers",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.10",
|
|
31
|
+
"Programming Language :: Python :: 3.11",
|
|
32
|
+
"Programming Language :: Python :: 3.12",
|
|
33
|
+
"Programming Language :: Python :: 3.13",
|
|
34
|
+
"Programming Language :: Python :: 3.14",
|
|
35
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
36
|
+
"Topic :: Security :: Cryptography",
|
|
37
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
38
|
+
"Typing :: Typed",
|
|
39
|
+
]
|
|
40
|
+
dependencies = [
|
|
41
|
+
"cryptography~=46.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = [
|
|
46
|
+
"granian~=2.0", # Rust ASGI server for E2E tests
|
|
47
|
+
"hypothesis~=6.148", # Property-based fuzz testing
|
|
48
|
+
"pyright~=1.1",
|
|
49
|
+
"pytest-asyncio~=0.24",
|
|
50
|
+
"pytest-cov~=7.0",
|
|
51
|
+
"pytest-mock~=3.14",
|
|
52
|
+
"pytest-xdist[psutil]~=3.8",
|
|
53
|
+
"pytest~=8.3",
|
|
54
|
+
"ruff~=0.7",
|
|
55
|
+
"twine~=6.1", # Package verification before PyPI upload
|
|
56
|
+
"typing_extensions~=4.12", # assert_type for API contract tests (Python 3.10)
|
|
57
|
+
"vulture~=2.14",
|
|
58
|
+
]
|
|
59
|
+
# For downstream services using testing fixtures
|
|
60
|
+
testing = [
|
|
61
|
+
"pytest-asyncio~=0.24",
|
|
62
|
+
"pytest~=8.3",
|
|
63
|
+
]
|
|
64
|
+
# FastAPI middleware (optional for server-side)
|
|
65
|
+
fastapi = [
|
|
66
|
+
"starlette~=0.50", # Upgraded from ~=0.41 for security fixes
|
|
67
|
+
]
|
|
68
|
+
# aiohttp client (optional for client-side)
|
|
69
|
+
aiohttp = [
|
|
70
|
+
"aiohttp~=3.13", # Upgraded from ~=3.11 for security fixes
|
|
71
|
+
]
|
|
72
|
+
# Zstd compression (optional, RFC 8878)
|
|
73
|
+
zstd = [
|
|
74
|
+
"backports.zstd>=1.0; python_version<'3.14'",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[project.urls]
|
|
78
|
+
Homepage = "https://github.com/dualeai/hpke-http"
|
|
79
|
+
Documentation = "https://github.com/dualeai/hpke-http#readme"
|
|
80
|
+
Repository = "https://github.com/dualeai/hpke-http.git"
|
|
81
|
+
Changelog = "https://github.com/dualeai/hpke-http/releases"
|
|
82
|
+
Issues = "https://github.com/dualeai/hpke-http/issues"
|
|
83
|
+
|
|
84
|
+
[build-system]
|
|
85
|
+
requires = ["setuptools>=78.1.1"] # Pinned for CVE-2025-47273
|
|
86
|
+
build-backend = "setuptools.build_meta"
|
|
87
|
+
|
|
88
|
+
[tool.setuptools.packages.find]
|
|
89
|
+
where = ["src"]
|
|
90
|
+
include = ["hpke_http*"]
|
|
91
|
+
|
|
92
|
+
[tool.setuptools.package-data]
|
|
93
|
+
hpke_http = ["py.typed"]
|
|
94
|
+
|
|
95
|
+
[tool.pytest.ini_options]
|
|
96
|
+
testpaths = ["tests"]
|
|
97
|
+
asyncio_mode = "auto"
|
|
98
|
+
addopts = [
|
|
99
|
+
"--cov=hpke_http", # Package name
|
|
100
|
+
"--cov-report=term-missing", # Show missing lines in terminal
|
|
101
|
+
"--cov-report=html", # Generate HTML report
|
|
102
|
+
"--cov-fail-under=80", # Require 80% minimum coverage
|
|
103
|
+
"--tb=short", # Short traceback format
|
|
104
|
+
"-v", # Verbose output, easier LLM debugging
|
|
105
|
+
"--strict-markers", # Ensure all markers are registered
|
|
106
|
+
"-n", # Run tests in parallel
|
|
107
|
+
"auto", # Use all available CPUs
|
|
108
|
+
]
|
|
109
|
+
markers = [
|
|
110
|
+
"vectors: RFC 9180 test vector validation (slow, 74MB JSON)",
|
|
111
|
+
"fuzz: Property-based fuzz tests using Hypothesis",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
# Subprocess coverage for granian E2E tests (pytest-cov 7.x)
|
|
115
|
+
[tool.coverage.run]
|
|
116
|
+
patch = ["subprocess", "fork", "_exit"]
|
|
117
|
+
parallel = true
|
|
118
|
+
sigterm = true
|
|
119
|
+
|
|
120
|
+
# NOTE: Rules for static testing are standard across all the monorepo, those rules are strict and must be followed, in any exception case please discuss with the team, in the meantime, if it fails, fix the issue, NEVER turn off the linter.
|
|
121
|
+
|
|
122
|
+
[tool.pyright]
|
|
123
|
+
typeCheckingMode = "strict"
|
|
124
|
+
venv = ".venv"
|
|
125
|
+
venvPath = "."
|
|
126
|
+
exclude = [".venv", "vulture_whitelist.py"]
|
|
127
|
+
|
|
128
|
+
[tool.ruff]
|
|
129
|
+
line-length = 120
|
|
130
|
+
|
|
131
|
+
[tool.ruff.lint]
|
|
132
|
+
select = [
|
|
133
|
+
"A", # flake8-builtins
|
|
134
|
+
"ARG", # flake8-unused-arguments
|
|
135
|
+
"ASYNC", # flake8-async
|
|
136
|
+
"B", # flake8-bugbear
|
|
137
|
+
"BLE", # flake8-blind-except
|
|
138
|
+
"DTZ", # flake8-datetimez
|
|
139
|
+
"E", # pycodestyle errors
|
|
140
|
+
"F", # pyflakes
|
|
141
|
+
"FAST", # FastAPI
|
|
142
|
+
"FBT", # flake8-boolean-trap
|
|
143
|
+
"I", # isort
|
|
144
|
+
"N", # pep8-naming
|
|
145
|
+
"PERF", # Perflint
|
|
146
|
+
"PIE", # flake8-pie
|
|
147
|
+
"PL", # Pylint
|
|
148
|
+
"PYI", # flake8-pyi
|
|
149
|
+
"Q", # flake8-quotes
|
|
150
|
+
"RET", # flake8-return
|
|
151
|
+
"RUF", # Ruff-specific
|
|
152
|
+
"S", # flake8-bandit (security)
|
|
153
|
+
"SIM", # flake8-simplify
|
|
154
|
+
"SLF", # flake8-self
|
|
155
|
+
"T20", # flake8-print
|
|
156
|
+
"TID", # flake8-tidy-imports
|
|
157
|
+
"UP", # pyupgrade
|
|
158
|
+
"W", # pycodestyle warnings
|
|
159
|
+
]
|
|
160
|
+
ignore = ["PLR0913", "RUF012", "S311"] # S311: random for non-crypto (we use secrets)
|
|
161
|
+
|
|
162
|
+
[tool.ruff.lint.per-file-ignores]
|
|
163
|
+
"tests/**/*.py" = ["PLR2004", "S101", "S603", "ASYNC109", "ASYNC220", "SIM117", "F841", "RUF059", "PLC0415"] # Tests: magic values, assert, subprocess, async patterns, nested with, unused vars, local imports
|
|
164
|
+
"tests/**/test_rfc9180*.py" = ["SLF001"] # Vector tests need internal state access
|
|
165
|
+
"tests/**/test_fuzz.py" = ["SLF001"] # Fuzz tests need internal state access for overflow testing
|
|
166
|
+
"tests/**/test_aiohttp_client.py" = ["SLF001"] # Unit tests for private parsing methods
|
|
167
|
+
"tests/**/test_streaming_errors.py" = ["SLF001"] # Error tests need internal cipher access
|
|
168
|
+
"src/**/streaming.py" = ["PLW0603", "PLC0415"] # Optional zstd: module caching requires global, lazy import for optional deps
|
|
169
|
+
|
|
170
|
+
[tool.vulture]
|
|
171
|
+
exclude = [".venv/"] # Exclude deps
|
|
172
|
+
paths = ["src", "tests", "vulture_whitelist.py"] # Include whitelist
|
|
173
|
+
min_confidence = 80 # Ignore crap detections
|
|
174
|
+
sort_by_size = true # Most impactful issues first
|