hpke-http 0.1.2__py3-none-any.whl
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/__init__.py +52 -0
- hpke_http/constants.py +232 -0
- hpke_http/envelope.py +167 -0
- hpke_http/exceptions.py +80 -0
- hpke_http/headers.py +48 -0
- hpke_http/hpke.py +375 -0
- hpke_http/middleware/__init__.py +9 -0
- hpke_http/middleware/aiohttp.py +392 -0
- hpke_http/middleware/fastapi.py +482 -0
- hpke_http/primitives/__init__.py +19 -0
- hpke_http/primitives/aead.py +175 -0
- hpke_http/primitives/kdf.py +116 -0
- hpke_http/primitives/kem.py +211 -0
- hpke_http/py.typed +0 -0
- hpke_http/streaming.py +329 -0
- hpke_http-0.1.2.dist-info/METADATA +266 -0
- hpke_http-0.1.2.dist-info/RECORD +19 -0
- hpke_http-0.1.2.dist-info/WHEEL +5 -0
- hpke_http-0.1.2.dist-info/top_level.txt +1 -0
hpke_http/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RFC 9180 HPKE encryption library for HTTP transport.
|
|
3
|
+
|
|
4
|
+
This library provides transparent end-to-end encryption for SDK ↔ Platform communication
|
|
5
|
+
using RFC 9180 HPKE (Hybrid Public Key Encryption) with PSK mode.
|
|
6
|
+
|
|
7
|
+
Usage (Server - FastAPI):
|
|
8
|
+
from hpke_http.middleware.fastapi import HPKEMiddleware
|
|
9
|
+
|
|
10
|
+
app = FastAPI()
|
|
11
|
+
app.add_middleware(HPKEMiddleware, private_key=settings.hpke_private_key)
|
|
12
|
+
|
|
13
|
+
Usage (Client - aiohttp):
|
|
14
|
+
from hpke_http.middleware.aiohttp import HPKEClientSession
|
|
15
|
+
|
|
16
|
+
async with HPKEClientSession(base_url="https://api.example.com", psk=api_key) as session:
|
|
17
|
+
async with session.post("/tasks", json=data) as response:
|
|
18
|
+
async for event in session.iter_sse(response):
|
|
19
|
+
print(event)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from importlib.metadata import version
|
|
23
|
+
|
|
24
|
+
from hpke_http.constants import AEAD_ID, KDF_ID, KEM_ID, MODE_PSK, VERSION
|
|
25
|
+
from hpke_http.exceptions import (
|
|
26
|
+
CryptoError,
|
|
27
|
+
DecryptionError,
|
|
28
|
+
EnvelopeError,
|
|
29
|
+
InvalidPSKError,
|
|
30
|
+
UnsupportedSuiteError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
# Constants
|
|
35
|
+
"AEAD_ID",
|
|
36
|
+
"KDF_ID",
|
|
37
|
+
"KEM_ID",
|
|
38
|
+
"MODE_PSK",
|
|
39
|
+
"VERSION",
|
|
40
|
+
# Exceptions
|
|
41
|
+
"CryptoError",
|
|
42
|
+
"DecryptionError",
|
|
43
|
+
"EnvelopeError",
|
|
44
|
+
"InvalidPSKError",
|
|
45
|
+
"UnsupportedSuiteError",
|
|
46
|
+
# Versioning
|
|
47
|
+
"__version__",
|
|
48
|
+
"__version_full__",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
__version__ = version("hpke_http")
|
|
52
|
+
__version_full__ = "0.1.2-8c80fcb+HEAD.20260107115221"
|
hpke_http/constants.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RFC 9180 HPKE constants and algorithm identifiers.
|
|
3
|
+
|
|
4
|
+
Cipher Suite:
|
|
5
|
+
- KEM: DHKEM(X25519, HKDF-SHA256) - 0x0020
|
|
6
|
+
- KDF: HKDF-SHA256 - 0x0001
|
|
7
|
+
- AEAD: ChaCha20-Poly1305 - 0x0003
|
|
8
|
+
- Mode: PSK - 0x01
|
|
9
|
+
|
|
10
|
+
References:
|
|
11
|
+
- RFC 9180 §7.1 (KEM IDs)
|
|
12
|
+
- RFC 9180 §7.2 (KDF IDs)
|
|
13
|
+
- RFC 9180 §7.3 (AEAD IDs)
|
|
14
|
+
- RFC 9180 §5.1.2 (PSK mode)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from enum import IntEnum
|
|
18
|
+
from typing import Final
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# HPKE Version
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
VERSION: Final[bytes] = b"HPKE-v1"
|
|
25
|
+
"""HPKE version string used in labeled operations (RFC 9180 §4)."""
|
|
26
|
+
|
|
27
|
+
# =============================================================================
|
|
28
|
+
# KEM Identifiers (RFC 9180 §7.1)
|
|
29
|
+
# =============================================================================
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KemId(IntEnum):
|
|
33
|
+
"""Key Encapsulation Mechanism identifiers."""
|
|
34
|
+
|
|
35
|
+
DHKEM_X25519_HKDF_SHA256 = 0x0020
|
|
36
|
+
"""DHKEM(X25519, HKDF-SHA256) - RFC 9180 recommended."""
|
|
37
|
+
|
|
38
|
+
# Future: X-Wing (X25519 + ML-KEM-768) - draft-connolly-cfrg-xwing-kem
|
|
39
|
+
# X_WING = 0x647A
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Default KEM for this implementation
|
|
43
|
+
KEM_ID: Final[int] = KemId.DHKEM_X25519_HKDF_SHA256
|
|
44
|
+
|
|
45
|
+
# X25519 key sizes
|
|
46
|
+
X25519_PUBLIC_KEY_SIZE: Final[int] = 32
|
|
47
|
+
X25519_PRIVATE_KEY_SIZE: Final[int] = 32
|
|
48
|
+
X25519_SHARED_SECRET_SIZE: Final[int] = 32
|
|
49
|
+
X25519_ENC_SIZE: Final[int] = 32 # Encapsulated key size
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# KDF Identifiers (RFC 9180 §7.2)
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class KdfId(IntEnum):
|
|
57
|
+
"""Key Derivation Function identifiers."""
|
|
58
|
+
|
|
59
|
+
HKDF_SHA256 = 0x0001
|
|
60
|
+
"""HKDF-SHA256 - RFC 5869."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Default KDF for this implementation
|
|
64
|
+
KDF_ID: Final[int] = KdfId.HKDF_SHA256
|
|
65
|
+
|
|
66
|
+
# HKDF-SHA256 parameters
|
|
67
|
+
HKDF_SHA256_HASH_SIZE: Final[int] = 32
|
|
68
|
+
HKDF_SHA256_N_SECRET: Final[int] = 32 # Output size of Extract
|
|
69
|
+
HKDF_SHA256_N_H: Final[int] = 32 # Hash output size
|
|
70
|
+
|
|
71
|
+
# =============================================================================
|
|
72
|
+
# AEAD Identifiers (RFC 9180 §7.3)
|
|
73
|
+
# =============================================================================
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AeadId(IntEnum):
|
|
77
|
+
"""Authenticated Encryption with Associated Data identifiers."""
|
|
78
|
+
|
|
79
|
+
AES_128_GCM = 0x0001
|
|
80
|
+
AES_256_GCM = 0x0002
|
|
81
|
+
CHACHA20_POLY1305 = 0x0003
|
|
82
|
+
"""ChaCha20-Poly1305 - RFC 8439."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Default AEAD for this implementation
|
|
86
|
+
AEAD_ID: Final[int] = AeadId.CHACHA20_POLY1305
|
|
87
|
+
|
|
88
|
+
# ChaCha20-Poly1305 parameters (RFC 8439)
|
|
89
|
+
CHACHA20_POLY1305_KEY_SIZE: Final[int] = 32
|
|
90
|
+
CHACHA20_POLY1305_NONCE_SIZE: Final[int] = 12
|
|
91
|
+
CHACHA20_POLY1305_TAG_SIZE: Final[int] = 16
|
|
92
|
+
|
|
93
|
+
# =============================================================================
|
|
94
|
+
# HPKE Mode Identifiers (RFC 9180 §5)
|
|
95
|
+
# =============================================================================
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class HpkeMode(IntEnum):
|
|
99
|
+
"""HPKE operation modes."""
|
|
100
|
+
|
|
101
|
+
BASE = 0x00
|
|
102
|
+
"""Base mode - no additional keying material."""
|
|
103
|
+
PSK = 0x01
|
|
104
|
+
"""PSK mode - Pre-Shared Key for additional authentication."""
|
|
105
|
+
AUTH = 0x02
|
|
106
|
+
"""Auth mode - Sender authentication via static key."""
|
|
107
|
+
AUTH_PSK = 0x03
|
|
108
|
+
"""AuthPSK mode - Both sender auth and PSK."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Default mode for this implementation
|
|
112
|
+
MODE_PSK: Final[int] = HpkeMode.PSK
|
|
113
|
+
|
|
114
|
+
# PSK minimum size for cryptographic security (256 bits)
|
|
115
|
+
PSK_MIN_SIZE: Final[int] = 32
|
|
116
|
+
|
|
117
|
+
# =============================================================================
|
|
118
|
+
# Suite ID Construction (RFC 9180 §5.1)
|
|
119
|
+
# =============================================================================
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_suite_id(kem_id: int = KEM_ID, kdf_id: int = KDF_ID, aead_id: int = AEAD_ID) -> bytes:
|
|
123
|
+
"""
|
|
124
|
+
Build the suite_id for HPKE operations.
|
|
125
|
+
|
|
126
|
+
suite_id = "HPKE" || I2OSP(kem_id, 2) || I2OSP(kdf_id, 2) || I2OSP(aead_id, 2)
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
kem_id: KEM algorithm identifier
|
|
130
|
+
kdf_id: KDF algorithm identifier
|
|
131
|
+
aead_id: AEAD algorithm identifier
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
10-byte suite_id
|
|
135
|
+
"""
|
|
136
|
+
return b"HPKE" + kem_id.to_bytes(2, "big") + kdf_id.to_bytes(2, "big") + aead_id.to_bytes(2, "big")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Default suite ID for DHKEM(X25519, HKDF-SHA256), HKDF-SHA256, ChaCha20-Poly1305
|
|
140
|
+
SUITE_ID: Final[bytes] = build_suite_id()
|
|
141
|
+
|
|
142
|
+
# =============================================================================
|
|
143
|
+
# Wire Format Constants
|
|
144
|
+
# =============================================================================
|
|
145
|
+
|
|
146
|
+
# Envelope header size: version(1) + kem_id(2) + kdf_id(2) + aead_id(2) + mode(1) = 8 bytes
|
|
147
|
+
ENVELOPE_HEADER_SIZE: Final[int] = 8
|
|
148
|
+
|
|
149
|
+
# Current envelope version
|
|
150
|
+
ENVELOPE_VERSION: Final[int] = 0x01
|
|
151
|
+
|
|
152
|
+
# =============================================================================
|
|
153
|
+
# HTTP Header Names
|
|
154
|
+
# =============================================================================
|
|
155
|
+
|
|
156
|
+
HEADER_HPKE_ENC: Final[str] = "X-HPKE-Enc"
|
|
157
|
+
"""Header containing base64url-encoded encapsulated key."""
|
|
158
|
+
|
|
159
|
+
HEADER_HPKE_STREAM: Final[str] = "X-HPKE-Stream"
|
|
160
|
+
"""Header containing encrypted SSE session parameters."""
|
|
161
|
+
|
|
162
|
+
HEADER_HPKE_ENCODING: Final[str] = "X-HPKE-Encoding"
|
|
163
|
+
"""Header specifying compression algorithm for request body (RFC 8878)."""
|
|
164
|
+
|
|
165
|
+
# =============================================================================
|
|
166
|
+
# ASGI Scope Keys
|
|
167
|
+
# =============================================================================
|
|
168
|
+
|
|
169
|
+
SCOPE_HPKE_CONTEXT: Final[str] = "hpke_context"
|
|
170
|
+
"""ASGI scope key for storing HPKE recipient context after request decryption."""
|
|
171
|
+
|
|
172
|
+
# =============================================================================
|
|
173
|
+
# SSE Streaming Constants
|
|
174
|
+
# =============================================================================
|
|
175
|
+
|
|
176
|
+
SSE_SESSION_SALT_SIZE: Final[int] = 4
|
|
177
|
+
"""Random salt size for SSE session nonces (4 bytes)."""
|
|
178
|
+
|
|
179
|
+
SSE_COUNTER_SIZE: Final[int] = 4
|
|
180
|
+
"""Counter size in wire format (4 bytes, big-endian)."""
|
|
181
|
+
|
|
182
|
+
SSE_MAX_COUNTER: Final[int] = 2**32 - 1
|
|
183
|
+
"""Maximum counter value (4 billion events per session)."""
|
|
184
|
+
|
|
185
|
+
SSE_MAX_EVENT_SIZE: Final[int] = 64 * 1024 * 1024
|
|
186
|
+
"""Default maximum buffered SSE event size (64MB).
|
|
187
|
+
|
|
188
|
+
This is a DoS protection limit for incomplete events without proper \\n\\n boundaries.
|
|
189
|
+
Can be overridden via HPKEMiddleware's max_sse_event_size parameter.
|
|
190
|
+
|
|
191
|
+
Note: SSE is text-only (UTF-8). Binary data (images, documents) must be
|
|
192
|
+
base64-encoded, which adds ~33% overhead. A 48MB file becomes ~64MB in base64.
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
# Export label for deriving SSE session key from HPKE context
|
|
196
|
+
SSE_SESSION_KEY_LABEL: Final[bytes] = b"sse-session-key"
|
|
197
|
+
|
|
198
|
+
# =============================================================================
|
|
199
|
+
# Key Discovery
|
|
200
|
+
# =============================================================================
|
|
201
|
+
|
|
202
|
+
DISCOVERY_PATH: Final[str] = "/.well-known/hpke-keys"
|
|
203
|
+
"""Path for HPKE key discovery endpoint."""
|
|
204
|
+
|
|
205
|
+
DISCOVERY_CACHE_MAX_AGE: Final[int] = 86400
|
|
206
|
+
"""Default cache max-age for discovery response (24 hours)."""
|
|
207
|
+
|
|
208
|
+
# =============================================================================
|
|
209
|
+
# Compression Constants (RFC 8878 - Zstandard)
|
|
210
|
+
# =============================================================================
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class SSEEncodingId(IntEnum):
|
|
214
|
+
"""Encoding algorithm identifiers for SSE payloads.
|
|
215
|
+
|
|
216
|
+
First byte of decrypted SSE payload indicates compression algorithm.
|
|
217
|
+
Extensible for future algorithms (brotli, etc.).
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
IDENTITY = 0x00
|
|
221
|
+
"""No compression - raw plaintext."""
|
|
222
|
+
ZSTD = 0x01
|
|
223
|
+
"""Zstandard compression (RFC 8878)."""
|
|
224
|
+
# Reserved for future:
|
|
225
|
+
# BROTLI = 0x02 # Brotli (RFC 7932)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
ZSTD_COMPRESSION_LEVEL: Final[int] = 3
|
|
229
|
+
"""Zstd compression level (1-22). Level 3 = fast compression."""
|
|
230
|
+
|
|
231
|
+
ZSTD_MIN_SIZE: Final[int] = 64
|
|
232
|
+
"""Minimum payload size for compression. Smaller payloads skip compression."""
|
hpke_http/envelope.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire format for HPKE encrypted envelopes.
|
|
3
|
+
|
|
4
|
+
Envelope format (sent in HTTP body):
|
|
5
|
+
┌─────────┬─────────┬─────────┬─────────┬──────┬────────────┐
|
|
6
|
+
│Version │ KEM_ID │ KDF_ID │ AEAD_ID │ Mode │ Ciphertext │
|
|
7
|
+
│ (1B) │ (2B) │ (2B) │ (2B) │ (1B) │ (N+16B) │
|
|
8
|
+
└─────────┴─────────┴─────────┴─────────┴──────┴────────────┘
|
|
9
|
+
|
|
10
|
+
The encapsulated key (enc) is sent in the X-HPKE-Enc HTTP header.
|
|
11
|
+
|
|
12
|
+
Reference: RFC-065 §4.1
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from hpke_http.constants import (
|
|
18
|
+
AEAD_ID,
|
|
19
|
+
CHACHA20_POLY1305_TAG_SIZE,
|
|
20
|
+
ENVELOPE_HEADER_SIZE,
|
|
21
|
+
ENVELOPE_VERSION,
|
|
22
|
+
KDF_ID,
|
|
23
|
+
KEM_ID,
|
|
24
|
+
MODE_PSK,
|
|
25
|
+
)
|
|
26
|
+
from hpke_http.exceptions import EnvelopeError, UnsupportedSuiteError
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"EnvelopeHeader",
|
|
30
|
+
"decode_envelope",
|
|
31
|
+
"encode_envelope",
|
|
32
|
+
"parse_header",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class EnvelopeHeader:
|
|
38
|
+
"""Parsed envelope header."""
|
|
39
|
+
|
|
40
|
+
version: int
|
|
41
|
+
kem_id: int
|
|
42
|
+
kdf_id: int
|
|
43
|
+
aead_id: int
|
|
44
|
+
mode: int
|
|
45
|
+
|
|
46
|
+
def validate(self) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Validate that header uses supported cipher suite.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
EnvelopeError: If version is unsupported
|
|
52
|
+
UnsupportedSuiteError: If cipher suite is not supported
|
|
53
|
+
"""
|
|
54
|
+
if self.version != ENVELOPE_VERSION:
|
|
55
|
+
raise EnvelopeError(f"Unsupported envelope version: {self.version}")
|
|
56
|
+
|
|
57
|
+
if self.kem_id != KEM_ID or self.kdf_id != KDF_ID or self.aead_id != AEAD_ID:
|
|
58
|
+
raise UnsupportedSuiteError(self.kem_id, self.kdf_id, self.aead_id)
|
|
59
|
+
|
|
60
|
+
if self.mode != MODE_PSK:
|
|
61
|
+
raise EnvelopeError(f"Unsupported HPKE mode: {self.mode} (only PSK mode 0x01 supported)")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def encode_header(
|
|
65
|
+
version: int = ENVELOPE_VERSION,
|
|
66
|
+
kem_id: int = KEM_ID,
|
|
67
|
+
kdf_id: int = KDF_ID,
|
|
68
|
+
aead_id: int = AEAD_ID,
|
|
69
|
+
mode: int = MODE_PSK,
|
|
70
|
+
) -> bytes:
|
|
71
|
+
"""
|
|
72
|
+
Encode envelope header.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
version: Envelope format version
|
|
76
|
+
kem_id: KEM algorithm identifier
|
|
77
|
+
kdf_id: KDF algorithm identifier
|
|
78
|
+
aead_id: AEAD algorithm identifier
|
|
79
|
+
mode: HPKE mode
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
8-byte header
|
|
83
|
+
"""
|
|
84
|
+
return (
|
|
85
|
+
version.to_bytes(1, "big")
|
|
86
|
+
+ kem_id.to_bytes(2, "big")
|
|
87
|
+
+ kdf_id.to_bytes(2, "big")
|
|
88
|
+
+ aead_id.to_bytes(2, "big")
|
|
89
|
+
+ mode.to_bytes(1, "big")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_header(data: bytes) -> EnvelopeHeader:
|
|
94
|
+
"""
|
|
95
|
+
Parse envelope header from bytes.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
data: At least 8 bytes starting with header
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Parsed EnvelopeHeader
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
EnvelopeError: If data is too short
|
|
105
|
+
"""
|
|
106
|
+
if len(data) < ENVELOPE_HEADER_SIZE:
|
|
107
|
+
raise EnvelopeError(f"Envelope too short: {len(data)} bytes (minimum {ENVELOPE_HEADER_SIZE})")
|
|
108
|
+
|
|
109
|
+
return EnvelopeHeader(
|
|
110
|
+
version=data[0],
|
|
111
|
+
kem_id=int.from_bytes(data[1:3], "big"),
|
|
112
|
+
kdf_id=int.from_bytes(data[3:5], "big"),
|
|
113
|
+
aead_id=int.from_bytes(data[5:7], "big"),
|
|
114
|
+
mode=data[7],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def encode_envelope(ciphertext: bytes) -> bytes:
|
|
119
|
+
"""
|
|
120
|
+
Encode ciphertext into envelope format.
|
|
121
|
+
|
|
122
|
+
The encapsulated key (enc) should be sent separately in X-HPKE-Enc header.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
ciphertext: AEAD-encrypted data with authentication tag
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Complete envelope: header || ciphertext
|
|
129
|
+
"""
|
|
130
|
+
header = encode_header()
|
|
131
|
+
return header + ciphertext
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def decode_envelope(envelope: bytes) -> tuple[EnvelopeHeader, bytes]:
|
|
135
|
+
"""
|
|
136
|
+
Decode envelope into header and ciphertext.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
envelope: Complete envelope bytes
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (header, ciphertext)
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
EnvelopeError: If envelope is malformed
|
|
146
|
+
UnsupportedSuiteError: If cipher suite is not supported
|
|
147
|
+
"""
|
|
148
|
+
if len(envelope) < ENVELOPE_HEADER_SIZE + CHACHA20_POLY1305_TAG_SIZE:
|
|
149
|
+
raise EnvelopeError(
|
|
150
|
+
f"Envelope too short: {len(envelope)} bytes (minimum {ENVELOPE_HEADER_SIZE + CHACHA20_POLY1305_TAG_SIZE})"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
header = parse_header(envelope)
|
|
154
|
+
header.validate()
|
|
155
|
+
|
|
156
|
+
ciphertext = envelope[ENVELOPE_HEADER_SIZE:]
|
|
157
|
+
return (header, ciphertext)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def envelope_overhead() -> int:
|
|
161
|
+
"""
|
|
162
|
+
Calculate total overhead added by envelope encoding.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Overhead in bytes (header + AEAD tag)
|
|
166
|
+
"""
|
|
167
|
+
return ENVELOPE_HEADER_SIZE + CHACHA20_POLY1305_TAG_SIZE
|
hpke_http/exceptions.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exception hierarchy for hpke_http.
|
|
3
|
+
|
|
4
|
+
All crypto-related errors inherit from CryptoError for easy catching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CryptoError(Exception):
|
|
9
|
+
"""Base exception for all cryptographic errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DecryptionError(CryptoError):
|
|
13
|
+
"""Failed to decrypt ciphertext.
|
|
14
|
+
|
|
15
|
+
Possible causes:
|
|
16
|
+
- Wrong key
|
|
17
|
+
- Corrupted ciphertext
|
|
18
|
+
- Invalid authentication tag
|
|
19
|
+
- Nonce reuse detected
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class InvalidPSKError(CryptoError):
|
|
24
|
+
"""Pre-shared key validation failed.
|
|
25
|
+
|
|
26
|
+
The PSK (API key) doesn't match what was used for encryption.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UnsupportedSuiteError(CryptoError):
|
|
31
|
+
"""Cipher suite not supported.
|
|
32
|
+
|
|
33
|
+
The envelope uses a KEM/KDF/AEAD combination this implementation doesn't support.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, kem_id: int, kdf_id: int, aead_id: int) -> None:
|
|
37
|
+
self.kem_id = kem_id
|
|
38
|
+
self.kdf_id = kdf_id
|
|
39
|
+
self.aead_id = aead_id
|
|
40
|
+
super().__init__(f"Unsupported suite: kem=0x{kem_id:04x}, kdf=0x{kdf_id:04x}, aead=0x{aead_id:04x}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class EnvelopeError(CryptoError):
|
|
44
|
+
"""Invalid envelope format.
|
|
45
|
+
|
|
46
|
+
The encrypted envelope is malformed:
|
|
47
|
+
- Too short
|
|
48
|
+
- Invalid version
|
|
49
|
+
- Corrupted header
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class KeyDiscoveryError(CryptoError):
|
|
54
|
+
"""Failed to fetch or parse HPKE keys from discovery endpoint."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SequenceOverflowError(CryptoError):
|
|
58
|
+
"""Sequence counter has reached maximum value.
|
|
59
|
+
|
|
60
|
+
The HPKE context can no longer be used for encryption/decryption.
|
|
61
|
+
Nonce reuse would be catastrophic for ChaCha20-Poly1305 security.
|
|
62
|
+
Create a new context to continue.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SessionExpiredError(CryptoError):
|
|
67
|
+
"""SSE streaming session has expired or exhausted its counter."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ReplayAttackError(CryptoError):
|
|
71
|
+
"""Detected out-of-order or duplicate SSE event.
|
|
72
|
+
|
|
73
|
+
Counter validation failed, indicating potential replay attack.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, expected: int, received: int) -> None:
|
|
77
|
+
self.expected = expected
|
|
78
|
+
self.received = received
|
|
79
|
+
# Don't expose counter values in message to prevent information leakage
|
|
80
|
+
super().__init__("SSE event counter validation failed")
|
hpke_http/headers.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP header utilities for HPKE encryption.
|
|
3
|
+
|
|
4
|
+
Uses base64url encoding (RFC 4648 §5) for HTTP header safety.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
|
|
9
|
+
from hpke_http.constants import HEADER_HPKE_ENC, HEADER_HPKE_STREAM
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"HEADER_HPKE_ENC",
|
|
13
|
+
"HEADER_HPKE_STREAM",
|
|
14
|
+
"b64url_decode",
|
|
15
|
+
"b64url_encode",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def b64url_encode(data: bytes) -> str:
|
|
20
|
+
"""
|
|
21
|
+
Encode bytes to base64url string without padding.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
data: Raw bytes to encode
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
base64url encoded string (no padding)
|
|
28
|
+
"""
|
|
29
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def b64url_decode(s: str) -> bytes:
|
|
33
|
+
"""
|
|
34
|
+
Decode base64url string to bytes.
|
|
35
|
+
|
|
36
|
+
Handles missing padding automatically.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
s: base64url encoded string (with or without padding)
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Decoded bytes
|
|
43
|
+
"""
|
|
44
|
+
# Add padding if needed (base64 uses 4-byte blocks)
|
|
45
|
+
padding = len(s) % 4
|
|
46
|
+
if padding:
|
|
47
|
+
s += "=" * (4 - padding)
|
|
48
|
+
return base64.urlsafe_b64decode(s)
|