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