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.
- lebrel_encrypted/__init__.py +17 -0
- lebrel_encrypted/__main__.py +3 -0
- lebrel_encrypted/_vendor/__init__.py +1 -0
- lebrel_encrypted/_vendor/ehbp/LICENSE +22 -0
- lebrel_encrypted/_vendor/ehbp/__init__.py +54 -0
- lebrel_encrypted/_vendor/ehbp/_http.py +78 -0
- lebrel_encrypted/_vendor/ehbp/client.py +429 -0
- lebrel_encrypted/_vendor/ehbp/derive.py +172 -0
- lebrel_encrypted/_vendor/ehbp/errors.py +38 -0
- lebrel_encrypted/_vendor/ehbp/identity.py +154 -0
- lebrel_encrypted/_vendor/ehbp/protocol.py +33 -0
- lebrel_encrypted/_vendor/ehbp/py.typed +1 -0
- lebrel_encrypted/_vendor/ehbp/session.py +122 -0
- lebrel_encrypted/_vendor/ehbp/transport.py +277 -0
- lebrel_encrypted/cli.py +45 -0
- lebrel_encrypted/client.py +541 -0
- lebrel_encrypted/proof.py +211 -0
- lebrel_encrypted/proxy.py +280 -0
- lebrel_encrypted-0.2.0.dist-info/METADATA +248 -0
- lebrel_encrypted-0.2.0.dist-info/RECORD +23 -0
- lebrel_encrypted-0.2.0.dist-info/WHEEL +4 -0
- lebrel_encrypted-0.2.0.dist-info/entry_points.txt +2 -0
- lebrel_encrypted-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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 @@
|
|
|
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)
|