lebrel-encrypted 0.2.0__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.
@@ -0,0 +1,17 @@
1
+ """Encrypted Lebrel API client. Conversation dictionaries use OpenAI's schema."""
2
+
3
+ from .client import (
4
+ APIError,
5
+ Completion,
6
+ CompletionStream,
7
+ EncryptionError,
8
+ Lebrel,
9
+ LebrelError,
10
+ MODEL_ID,
11
+ StreamError,
12
+ TransportError,
13
+ )
14
+ from .proof import Check, Manifest, Receipt
15
+
16
+ __version__ = "0.2.0"
17
+ __all__ = ["Lebrel", "MODEL_ID", "Completion", "CompletionStream", "Receipt", "Manifest", "Check", "LebrelError", "EncryptionError", "TransportError", "APIError", "StreamError"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Unmodified third-party source; see THIRD_PARTY_NOTICES.md."""
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tinfoil, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,54 @@
1
+ """Python client for the encrypted HTTP body protocol (EHBP)."""
2
+
3
+ from . import protocol
4
+ from .client import Client, Response, StreamingResponse
5
+ from .derive import (
6
+ FrameDecryptor,
7
+ ResponseKeyMaterial,
8
+ compute_nonce,
9
+ decrypt_chunk,
10
+ derive_response_keys,
11
+ encrypt_chunk,
12
+ frame_chunk,
13
+ )
14
+ from .errors import (
15
+ CryptoError,
16
+ EHBPError,
17
+ HPKEError,
18
+ InvalidConfigError,
19
+ InvalidInputError,
20
+ KeyConfigMismatchError,
21
+ ProtocolError,
22
+ )
23
+ from .identity import EncryptedRequest, ServerIdentity
24
+ from .session import SessionRecoveryToken
25
+ from .transport import AsyncEHBPTransport, EHBPTransport
26
+
27
+ __version__ = "0.3.2"
28
+
29
+ __all__ = [
30
+ "Client",
31
+ "Response",
32
+ "StreamingResponse",
33
+ "EHBPTransport",
34
+ "AsyncEHBPTransport",
35
+ "ServerIdentity",
36
+ "EncryptedRequest",
37
+ "SessionRecoveryToken",
38
+ "FrameDecryptor",
39
+ "ResponseKeyMaterial",
40
+ "derive_response_keys",
41
+ "compute_nonce",
42
+ "encrypt_chunk",
43
+ "decrypt_chunk",
44
+ "frame_chunk",
45
+ "EHBPError",
46
+ "InvalidConfigError",
47
+ "InvalidInputError",
48
+ "ProtocolError",
49
+ "KeyConfigMismatchError",
50
+ "HPKEError",
51
+ "CryptoError",
52
+ "protocol",
53
+ "__version__",
54
+ ]
@@ -0,0 +1,78 @@
1
+ """Shared wire-level helpers for the high-level client and the httpx transports.
2
+
3
+ These implement concerns that are identical whether a request is driven through
4
+ the convenience :class:`~ehbp.client.Client` or an httpx transport: building a
5
+ chunked single-frame request body, recognising the key-configuration-mismatch
6
+ problem response, and parsing the response nonce header.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from collections.abc import Iterator
13
+ from typing import Optional
14
+
15
+ import httpx
16
+
17
+ from .errors import KeyConfigMismatchError, ProtocolError
18
+ from .protocol import (
19
+ KEY_CONFIG_PROBLEM_TYPE,
20
+ PROBLEM_JSON_MEDIA_TYPE,
21
+ RESPONSE_NONCE_HEADER,
22
+ RESPONSE_NONCE_LENGTH,
23
+ )
24
+
25
+ DEFAULT_TIMEOUT = 30.0
26
+ DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024
27
+
28
+ KEY_CONFIG_MISMATCH_STATUS = 422
29
+
30
+
31
+ def single_chunk_body(body: bytes) -> Iterator[bytes]:
32
+ # Yielding from an iterator makes httpx use chunked transfer-encoding and
33
+ # omit Content-Length, as required for encrypted bodies (SPEC Section 4.1).
34
+ yield body
35
+
36
+
37
+ def media_type(headers: httpx.Headers) -> str:
38
+ raw = headers.get("content-type", "")
39
+ return raw.split(";", 1)[0].strip().lower()
40
+
41
+
42
+ def raise_for_key_config_mismatch(status: int, headers: httpx.Headers, body: bytes) -> None:
43
+ if status != KEY_CONFIG_MISMATCH_STATUS:
44
+ return
45
+ if media_type(headers) != PROBLEM_JSON_MEDIA_TYPE:
46
+ return
47
+ try:
48
+ problem = json.loads(body)
49
+ except (ValueError, TypeError):
50
+ return
51
+ if isinstance(problem, dict) and problem.get("type") == KEY_CONFIG_PROBLEM_TYPE:
52
+ title = problem.get("title")
53
+ if not isinstance(title, str):
54
+ title = "key configuration mismatch"
55
+ raise KeyConfigMismatchError(title)
56
+
57
+
58
+ def response_nonce(headers: httpx.Headers) -> bytes:
59
+ values = headers.get_list(RESPONSE_NONCE_HEADER)
60
+ if not values:
61
+ raise ProtocolError(f"missing {RESPONSE_NONCE_HEADER} header")
62
+ if len(values) > 1:
63
+ raise ProtocolError(f"multiple {RESPONSE_NONCE_HEADER} headers")
64
+ try:
65
+ nonce = bytes.fromhex(values[0].strip())
66
+ except ValueError as err:
67
+ raise ProtocolError(f"invalid response nonce header: {err}") from err
68
+ if len(nonce) != RESPONSE_NONCE_LENGTH:
69
+ raise ProtocolError(
70
+ f"invalid response nonce length: expected {RESPONSE_NONCE_LENGTH}, got {len(nonce)}"
71
+ )
72
+ return nonce
73
+
74
+
75
+ def response_nonce_for_status(status: int, headers: httpx.Headers) -> Optional[bytes]:
76
+ if RESPONSE_NONCE_HEADER not in headers and status // 100 != 2:
77
+ return None
78
+ return response_nonce(headers)
@@ -0,0 +1,429 @@
1
+ """Synchronous EHBP client transport built on httpx.
2
+
3
+ The client encrypts request bodies to the server's HPKE public key and decrypts
4
+ the bound response body. It passes through nonce-less non-success responses,
5
+ fails closed on nonce-less successes or invalid nonces, and enforces several
6
+ defensive constraints (single configured origin, no credentials in URLs,
7
+ reserved protocol headers cannot be overridden, redirects disabled, response
8
+ size capped).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import threading
15
+ from collections.abc import Iterator, Mapping
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass
18
+ from typing import Any, Optional, Union
19
+
20
+ import httpx
21
+
22
+ from ._http import (
23
+ DEFAULT_MAX_RESPONSE_BYTES,
24
+ DEFAULT_TIMEOUT,
25
+ )
26
+ from ._http import (
27
+ raise_for_key_config_mismatch as _raise_for_key_config_mismatch,
28
+ )
29
+ from ._http import (
30
+ response_nonce_for_status as _response_nonce_for_status,
31
+ )
32
+ from ._http import (
33
+ single_chunk_body as _single_chunk_body,
34
+ )
35
+ from .errors import InvalidInputError, ProtocolError
36
+ from .identity import ServerIdentity
37
+ from .protocol import (
38
+ ENCAPSULATED_KEY_HEADER,
39
+ KEYS_MEDIA_TYPE,
40
+ KEYS_PATH,
41
+ RESPONSE_NONCE_HEADER,
42
+ )
43
+ from .session import SessionRecoveryToken
44
+
45
+ _RESERVED_REQUEST_HEADERS = frozenset(
46
+ {
47
+ "content-length",
48
+ "transfer-encoding",
49
+ "host",
50
+ ENCAPSULATED_KEY_HEADER.lower(),
51
+ RESPONSE_NONCE_HEADER.lower(),
52
+ }
53
+ )
54
+
55
+ Body = Union[bytes, bytearray, str, None]
56
+ HeadersInput = Optional[Mapping[str, str]]
57
+
58
+
59
+ @dataclass
60
+ class Response:
61
+ status_code: int
62
+ headers: httpx.Headers
63
+ content: bytes
64
+
65
+ def text(self, encoding: str = "utf-8") -> str:
66
+ return self.content.decode(encoding)
67
+
68
+ def json(self) -> Any:
69
+ return json.loads(self.content)
70
+
71
+
72
+ @dataclass
73
+ class StreamingResponse:
74
+ status_code: int
75
+ headers: httpx.Headers
76
+ _chunks: Iterator[bytes]
77
+
78
+ def __iter__(self) -> Iterator[bytes]:
79
+ return self._chunks
80
+
81
+ def iter_bytes(self) -> Iterator[bytes]:
82
+ return self._chunks
83
+
84
+
85
+ class Client:
86
+ def __init__(
87
+ self,
88
+ base_url: Union[str, httpx.URL],
89
+ identity: ServerIdentity,
90
+ *,
91
+ http_client: Optional[httpx.Client] = None,
92
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
93
+ ) -> None:
94
+ self._base_url = _normalize_base_url(base_url)
95
+ self._identity = identity
96
+ self._http = http_client or _default_http_client()
97
+ self._max_response_bytes = max_response_bytes
98
+ self._token_lock = threading.Lock()
99
+ self._last_token: Optional[SessionRecoveryToken] = None
100
+ self._request_generation = 0
101
+
102
+ @classmethod
103
+ def discover(
104
+ cls,
105
+ base_url: str,
106
+ *,
107
+ http_client: Optional[httpx.Client] = None,
108
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
109
+ ) -> Client:
110
+ """Fetch the server key configuration and build a client."""
111
+ base = _normalize_base_url(base_url)
112
+ http = http_client or _default_http_client()
113
+ response = http.get(base.join(KEYS_PATH), follow_redirects=False)
114
+ if response.status_code // 100 != 2:
115
+ raise ProtocolError(
116
+ f"server returned status {response.status_code} while fetching key configuration"
117
+ )
118
+ content_type = response.headers.get("content-type", "")
119
+ if content_type != KEYS_MEDIA_TYPE:
120
+ raise ProtocolError(f"server returned invalid key content type: {content_type}")
121
+ identity = ServerIdentity.unmarshal_public_config(response.content)
122
+ return cls(
123
+ base,
124
+ identity,
125
+ http_client=http,
126
+ max_response_bytes=max_response_bytes,
127
+ )
128
+
129
+ @classmethod
130
+ def with_config(
131
+ cls,
132
+ base_url: str,
133
+ hpke_config: bytes,
134
+ *,
135
+ http_client: Optional[httpx.Client] = None,
136
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
137
+ ) -> Client:
138
+ identity = ServerIdentity.unmarshal_public_config(hpke_config)
139
+ return cls(
140
+ base_url,
141
+ identity,
142
+ http_client=http_client,
143
+ max_response_bytes=max_response_bytes,
144
+ )
145
+
146
+ @classmethod
147
+ def with_public_key_hex(
148
+ cls,
149
+ base_url: str,
150
+ public_key_hex: str,
151
+ *,
152
+ http_client: Optional[httpx.Client] = None,
153
+ max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
154
+ ) -> Client:
155
+ identity = ServerIdentity.from_public_key_hex(public_key_hex)
156
+ return cls(
157
+ base_url,
158
+ identity,
159
+ http_client=http_client,
160
+ max_response_bytes=max_response_bytes,
161
+ )
162
+
163
+ @property
164
+ def server_identity(self) -> ServerIdentity:
165
+ return self._identity
166
+
167
+ def get_session_recovery_token(self) -> Optional[SessionRecoveryToken]:
168
+ with self._token_lock:
169
+ return self._last_token
170
+
171
+ def take_session_recovery_token(self) -> Optional[SessionRecoveryToken]:
172
+ with self._token_lock:
173
+ token = self._last_token
174
+ self._last_token = None
175
+ return token
176
+
177
+ def close(self) -> None:
178
+ self._http.close()
179
+
180
+ def __enter__(self) -> Client:
181
+ return self
182
+
183
+ def __exit__(self, *_exc: object) -> None:
184
+ self.close()
185
+
186
+ def request(
187
+ self,
188
+ method: str,
189
+ path_or_url: str,
190
+ *,
191
+ body: Body = None,
192
+ json_body: Any = None,
193
+ headers: HeadersInput = None,
194
+ ) -> Response:
195
+ url = self._resolve_url(path_or_url)
196
+ request_headers, plaintext = self._prepare_body(headers, body, json_body)
197
+ generation = self._begin_request()
198
+ encrypted = self._identity.encrypt_request_body(plaintext)
199
+
200
+ if encrypted is None:
201
+ with self._http.stream(
202
+ method, url, headers=request_headers, content=None, follow_redirects=False
203
+ ) as resp:
204
+ raw = self._read_body_capped(resp)
205
+ return Response(resp.status_code, resp.headers, raw)
206
+
207
+ request_headers[ENCAPSULATED_KEY_HEADER] = encrypted.encapsulated_key.hex()
208
+ token = encrypted.token
209
+ self._publish_token(generation, token)
210
+ try:
211
+ with self._http.stream(
212
+ method,
213
+ url,
214
+ headers=request_headers,
215
+ content=_single_chunk_body(encrypted.body),
216
+ follow_redirects=False,
217
+ ) as resp:
218
+ status = resp.status_code
219
+ response_headers = resp.headers
220
+ raw = self._read_body_capped(resp)
221
+ _raise_for_key_config_mismatch(status, response_headers, raw)
222
+ response_nonce = _response_nonce_for_status(status, response_headers)
223
+ if response_nonce is None:
224
+ self._clear_token_if_current(generation)
225
+ return Response(status, response_headers, raw)
226
+ decrypted = token.decrypt_response_body(response_nonce, raw)
227
+ except BaseException:
228
+ self._clear_token_if_current(generation)
229
+ raise
230
+ self._clear_token_if_current(generation)
231
+ return Response(status, response_headers, decrypted)
232
+
233
+ def get(self, path_or_url: str, *, headers: HeadersInput = None) -> Response:
234
+ return self.request("GET", path_or_url, headers=headers)
235
+
236
+ def delete(self, path_or_url: str, *, headers: HeadersInput = None) -> Response:
237
+ return self.request("DELETE", path_or_url, headers=headers)
238
+
239
+ def post(
240
+ self,
241
+ path_or_url: str,
242
+ *,
243
+ body: Body = None,
244
+ json_body: Any = None,
245
+ headers: HeadersInput = None,
246
+ ) -> Response:
247
+ return self.request("POST", path_or_url, body=body, json_body=json_body, headers=headers)
248
+
249
+ def put(
250
+ self,
251
+ path_or_url: str,
252
+ *,
253
+ body: Body = None,
254
+ json_body: Any = None,
255
+ headers: HeadersInput = None,
256
+ ) -> Response:
257
+ return self.request("PUT", path_or_url, body=body, json_body=json_body, headers=headers)
258
+
259
+ @contextmanager
260
+ def stream(
261
+ self,
262
+ method: str,
263
+ path_or_url: str,
264
+ *,
265
+ body: Body = None,
266
+ json_body: Any = None,
267
+ headers: HeadersInput = None,
268
+ ) -> Iterator[StreamingResponse]:
269
+ url = self._resolve_url(path_or_url)
270
+ request_headers, plaintext = self._prepare_body(headers, body, json_body)
271
+ generation = self._begin_request()
272
+ encrypted = self._identity.encrypt_request_body(plaintext)
273
+
274
+ if encrypted is None:
275
+ with self._http.stream(
276
+ method, url, headers=request_headers, content=None, follow_redirects=False
277
+ ) as resp:
278
+ yield StreamingResponse(resp.status_code, resp.headers, resp.iter_bytes())
279
+ return
280
+
281
+ request_headers[ENCAPSULATED_KEY_HEADER] = encrypted.encapsulated_key.hex()
282
+ token = encrypted.token
283
+ self._publish_token(generation, token)
284
+ try:
285
+ with self._http.stream(
286
+ method,
287
+ url,
288
+ headers=request_headers,
289
+ content=_single_chunk_body(encrypted.body),
290
+ follow_redirects=False,
291
+ ) as resp:
292
+ status = resp.status_code
293
+ response_headers = resp.headers
294
+ if RESPONSE_NONCE_HEADER not in response_headers:
295
+ raw = self._read_body_capped(resp)
296
+ self._clear_token_if_current(generation)
297
+ _raise_for_key_config_mismatch(status, response_headers, raw)
298
+ response_nonce = _response_nonce_for_status(status, response_headers)
299
+ if response_nonce is None:
300
+ yield StreamingResponse(status, response_headers, iter((raw,)))
301
+ return
302
+ else:
303
+ response_nonce = _response_nonce_for_status(status, response_headers)
304
+ assert response_nonce is not None
305
+ yield StreamingResponse(
306
+ status,
307
+ response_headers,
308
+ self._decrypt_stream(resp, token, response_nonce, generation),
309
+ )
310
+ except GeneratorExit:
311
+ raise
312
+ except BaseException:
313
+ self._clear_token_if_current(generation)
314
+ raise
315
+
316
+ def _decrypt_stream(
317
+ self,
318
+ resp: httpx.Response,
319
+ token: SessionRecoveryToken,
320
+ response_nonce: bytes,
321
+ generation: int,
322
+ ) -> Iterator[bytes]:
323
+ decryptor = token.create_response_decryptor(
324
+ response_nonce, max_chunk_length=self._max_response_bytes
325
+ )
326
+ try:
327
+ for chunk in resp.iter_bytes():
328
+ yield from decryptor.push(chunk)
329
+ decryptor.finish()
330
+ self._clear_token_if_current(generation)
331
+ except GeneratorExit:
332
+ raise
333
+ except BaseException:
334
+ self._clear_token_if_current(generation)
335
+ raise
336
+
337
+ def _prepare_body(
338
+ self, headers: HeadersInput, body: Body, json_body: Any
339
+ ) -> tuple[httpx.Headers, bytes]:
340
+ request_headers = self._prepare_headers(headers)
341
+ if json_body is not None:
342
+ if body is not None:
343
+ raise InvalidInputError("provide either body or json_body, not both")
344
+ request_headers["content-type"] = "application/json"
345
+ return request_headers, json.dumps(json_body).encode("utf-8")
346
+ return request_headers, _as_bytes(body)
347
+
348
+ def _prepare_headers(self, headers: HeadersInput) -> httpx.Headers:
349
+ prepared = httpx.Headers(headers or {})
350
+ for name in prepared:
351
+ if name.lower() in _RESERVED_REQUEST_HEADERS:
352
+ raise InvalidInputError(
353
+ f"reserved request header cannot be set by callers: {name}"
354
+ )
355
+ return prepared
356
+
357
+ def _read_body_capped(self, resp: httpx.Response) -> bytes:
358
+ chunks = []
359
+ total = 0
360
+ for chunk in resp.iter_bytes():
361
+ total += len(chunk)
362
+ if total > self._max_response_bytes:
363
+ raise ProtocolError("response body exceeds maximum allowed size")
364
+ chunks.append(chunk)
365
+ return b"".join(chunks)
366
+
367
+ def _resolve_url(self, path_or_url: str) -> httpx.URL:
368
+ url = self._base_url.join(path_or_url)
369
+ if not _same_origin(self._base_url, url):
370
+ raise InvalidInputError(
371
+ f"request URL must use the configured origin: "
372
+ f"{self._base_url.scheme}://{self._base_url.netloc.decode('ascii')}"
373
+ )
374
+ if url.username or url.password:
375
+ raise InvalidInputError("request URL must not include credentials")
376
+ return url
377
+
378
+ def _begin_request(self) -> int:
379
+ with self._token_lock:
380
+ self._request_generation += 1
381
+ self._last_token = None
382
+ return self._request_generation
383
+
384
+ def _publish_token(self, generation: int, token: SessionRecoveryToken) -> None:
385
+ with self._token_lock:
386
+ if self._request_generation == generation:
387
+ self._last_token = token
388
+
389
+ def _clear_token_if_current(self, generation: int) -> None:
390
+ with self._token_lock:
391
+ if self._request_generation == generation:
392
+ self._last_token = None
393
+
394
+
395
+ def _default_http_client() -> httpx.Client:
396
+ return httpx.Client(follow_redirects=False, timeout=DEFAULT_TIMEOUT)
397
+
398
+
399
+ def _as_bytes(body: Body) -> bytes:
400
+ if body is None:
401
+ return b""
402
+ if isinstance(body, str):
403
+ return body.encode("utf-8")
404
+ if isinstance(body, (bytes, bytearray)):
405
+ return bytes(body)
406
+ raise InvalidInputError("body must be bytes, str, or None")
407
+
408
+
409
+ def _normalize_base_url(raw: Union[str, httpx.URL]) -> httpx.URL:
410
+ url = httpx.URL(raw)
411
+ if not url.host:
412
+ raise InvalidInputError("base URL must include an HTTP origin")
413
+ if url.username or url.password:
414
+ raise InvalidInputError("base URL must not include credentials")
415
+ if url.scheme not in ("http", "https"):
416
+ raise InvalidInputError("base URL scheme must be http or https")
417
+ path = url.path or "/"
418
+ if not path.endswith("/"):
419
+ path = path + "/"
420
+ return httpx.URL(scheme=url.scheme, host=url.host, port=url.port, path=path)
421
+
422
+
423
+ def _origin(url: httpx.URL) -> tuple[str, Optional[str], int]:
424
+ port = url.port if url.port is not None else (443 if url.scheme == "https" else 80)
425
+ return (url.scheme, url.host, port)
426
+
427
+
428
+ def _same_origin(left: httpx.URL, right: httpx.URL) -> bool:
429
+ return _origin(left) == _origin(right)