exfer 0.10.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.
- exfer/__init__.py +67 -0
- exfer/_params.py +157 -0
- exfer/_transport.py +271 -0
- exfer/_version.py +1 -0
- exfer/async_client.py +744 -0
- exfer/client.py +916 -0
- exfer/errors.py +275 -0
- exfer/py.typed +0 -0
- exfer/types.py +524 -0
- exfer-0.10.0.dist-info/METADATA +168 -0
- exfer-0.10.0.dist-info/RECORD +13 -0
- exfer-0.10.0.dist-info/WHEEL +4 -0
- exfer-0.10.0.dist-info/licenses/LICENSE +21 -0
exfer/__init__.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Typed Python client for the exfer-walletd JSON-RPC API.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from exfer import Client
|
|
6
|
+
|
|
7
|
+
with Client("http://127.0.0.1:7448", token="...") as c:
|
|
8
|
+
res = c.generate_address() # → {address, pubkey, index}
|
|
9
|
+
print(c.get_balance(res["address"])) # → int
|
|
10
|
+
|
|
11
|
+
Every method maps 1:1 to a walletd JSON-RPC method; see
|
|
12
|
+
https://exfer-stack.github.io/exfer-py/.
|
|
13
|
+
|
|
14
|
+
Result *types* live in :mod:`exfer.types` (``Block``,
|
|
15
|
+
``Transaction``, ``Tip``, …) — import from there if you want to
|
|
16
|
+
annotate variables; you don't need to touch them for normal use.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .async_client import AsyncClient
|
|
23
|
+
from .client import Client
|
|
24
|
+
from .errors import (
|
|
25
|
+
AuthenticationError,
|
|
26
|
+
ExferError,
|
|
27
|
+
FingerprintMismatchError,
|
|
28
|
+
IndexerNotConfiguredError,
|
|
29
|
+
InsufficientBalanceError,
|
|
30
|
+
InternalError,
|
|
31
|
+
InvalidParamsError,
|
|
32
|
+
MethodNotFoundError,
|
|
33
|
+
ParseError,
|
|
34
|
+
ProtocolError,
|
|
35
|
+
TransportError,
|
|
36
|
+
TxAuthError,
|
|
37
|
+
UpstreamError,
|
|
38
|
+
WaitTimeoutError,
|
|
39
|
+
WalletdError,
|
|
40
|
+
WalletExistsError,
|
|
41
|
+
WalletNotFoundError,
|
|
42
|
+
)
|
|
43
|
+
from .types import Tip
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AsyncClient",
|
|
47
|
+
"AuthenticationError",
|
|
48
|
+
"Client",
|
|
49
|
+
"ExferError",
|
|
50
|
+
"FingerprintMismatchError",
|
|
51
|
+
"IndexerNotConfiguredError",
|
|
52
|
+
"InsufficientBalanceError",
|
|
53
|
+
"InternalError",
|
|
54
|
+
"InvalidParamsError",
|
|
55
|
+
"MethodNotFoundError",
|
|
56
|
+
"ParseError",
|
|
57
|
+
"ProtocolError",
|
|
58
|
+
"Tip",
|
|
59
|
+
"TransportError",
|
|
60
|
+
"TxAuthError",
|
|
61
|
+
"UpstreamError",
|
|
62
|
+
"WaitTimeoutError",
|
|
63
|
+
"WalletExistsError",
|
|
64
|
+
"WalletNotFoundError",
|
|
65
|
+
"WalletdError",
|
|
66
|
+
"__version__",
|
|
67
|
+
]
|
exfer/_params.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Param-dict builders shared by :class:`Client` and :class:`AsyncClient`.
|
|
2
|
+
|
|
3
|
+
Each method's wire shape lives in exactly one place here so the sync and
|
|
4
|
+
async clients can't drift on parameter names or default-omission rules.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .types import HtlcRole, HtlcState
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"_addr_paginated_params",
|
|
16
|
+
"_htlc_list_params",
|
|
17
|
+
"_htlc_lock_params",
|
|
18
|
+
"_payment_uri_params",
|
|
19
|
+
"_simulate_transfer_params",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _htlc_lock_params(
|
|
24
|
+
from_: str,
|
|
25
|
+
receiver: str,
|
|
26
|
+
hash_lock: str,
|
|
27
|
+
timeout: int,
|
|
28
|
+
amount: int,
|
|
29
|
+
fee: int | None,
|
|
30
|
+
fee_rate: int | None,
|
|
31
|
+
max_fee: int | None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Shape shared by ``htlc_lock`` and ``simulate_htlc_lock``."""
|
|
34
|
+
if fee is not None and fee_rate is not None:
|
|
35
|
+
raise ValueError("`fee` and `fee_rate` are mutually exclusive")
|
|
36
|
+
params: dict[str, Any] = {
|
|
37
|
+
"from": from_,
|
|
38
|
+
"receiver": receiver,
|
|
39
|
+
"hash_lock": hash_lock,
|
|
40
|
+
"timeout": timeout,
|
|
41
|
+
"amount": amount,
|
|
42
|
+
}
|
|
43
|
+
if fee is not None:
|
|
44
|
+
params["fee"] = fee
|
|
45
|
+
if fee_rate is not None:
|
|
46
|
+
params["fee_rate"] = fee_rate
|
|
47
|
+
if max_fee is not None:
|
|
48
|
+
params["max_fee"] = max_fee
|
|
49
|
+
return params
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _simulate_transfer_params(
|
|
53
|
+
from_: str,
|
|
54
|
+
outputs: list[Mapping[str, Any]],
|
|
55
|
+
fee: int | None,
|
|
56
|
+
fee_rate: int | None,
|
|
57
|
+
max_fee: int | None,
|
|
58
|
+
datum: str | None,
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
"""Shape shared by simulate-side caller plumbing.
|
|
61
|
+
|
|
62
|
+
``outputs`` is forwarded as-is — walletd validates each entry's
|
|
63
|
+
``to`` / ``amount`` shape. Empty or 16+-entry lists are surfaced as
|
|
64
|
+
server-side ``InvalidParams`` rather than client-side guards.
|
|
65
|
+
|
|
66
|
+
``datum`` mirrors the real ``transfer`` datum: when given, walletd
|
|
67
|
+
counts its bytes toward the simulated tx size/fee so a honor settlement
|
|
68
|
+
(which carries the quote_id as a 16-byte datum) dry-runs to the same
|
|
69
|
+
size/fee the real transfer produces. Hex string, even length, <= 4096
|
|
70
|
+
bytes — length validation is left to walletd's ``InvalidParams``.
|
|
71
|
+
"""
|
|
72
|
+
if fee is not None and fee_rate is not None:
|
|
73
|
+
raise ValueError("`fee` and `fee_rate` are mutually exclusive")
|
|
74
|
+
params: dict[str, Any] = {"from": from_, "outputs": list(outputs)}
|
|
75
|
+
if fee is not None:
|
|
76
|
+
params["fee"] = fee
|
|
77
|
+
if fee_rate is not None:
|
|
78
|
+
params["fee_rate"] = fee_rate
|
|
79
|
+
if max_fee is not None:
|
|
80
|
+
params["max_fee"] = max_fee
|
|
81
|
+
if datum is not None:
|
|
82
|
+
params["datum"] = datum
|
|
83
|
+
return params
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _payment_uri_params(
|
|
87
|
+
address: str,
|
|
88
|
+
amount: int | None,
|
|
89
|
+
memo: str | None,
|
|
90
|
+
hash_lock: str | None,
|
|
91
|
+
timeout: int | None,
|
|
92
|
+
label: str | None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Shape for ``payment_uri_encode``. Unset fields are omitted from
|
|
95
|
+
the wire dict so the receiver's `Option<T>` deserializers see
|
|
96
|
+
explicit ``None``.
|
|
97
|
+
"""
|
|
98
|
+
params: dict[str, Any] = {"address": address}
|
|
99
|
+
if amount is not None:
|
|
100
|
+
params["amount"] = amount
|
|
101
|
+
if memo is not None:
|
|
102
|
+
params["memo"] = memo
|
|
103
|
+
if hash_lock is not None:
|
|
104
|
+
params["hash_lock"] = hash_lock
|
|
105
|
+
if timeout is not None:
|
|
106
|
+
params["timeout"] = timeout
|
|
107
|
+
if label is not None:
|
|
108
|
+
params["label"] = label
|
|
109
|
+
return params
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _htlc_list_params(
|
|
113
|
+
role: HtlcRole | None,
|
|
114
|
+
state: HtlcState | list[HtlcState] | None,
|
|
115
|
+
since_height: int | None,
|
|
116
|
+
address: str | None,
|
|
117
|
+
limit: int | None,
|
|
118
|
+
cursor: str | None,
|
|
119
|
+
) -> dict[str, Any]:
|
|
120
|
+
"""Shape for ``htlc_list``. ``state`` accepts either a single state
|
|
121
|
+
string or a list — walletd's deserializer handles both via an
|
|
122
|
+
untagged enum, so we forward whatever the caller gave.
|
|
123
|
+
"""
|
|
124
|
+
params: dict[str, Any] = {}
|
|
125
|
+
if role is not None:
|
|
126
|
+
params["role"] = role
|
|
127
|
+
if state is not None:
|
|
128
|
+
params["state"] = state
|
|
129
|
+
if since_height is not None:
|
|
130
|
+
params["since_height"] = since_height
|
|
131
|
+
if address is not None:
|
|
132
|
+
params["address"] = address
|
|
133
|
+
if limit is not None:
|
|
134
|
+
params["limit"] = limit
|
|
135
|
+
if cursor is not None:
|
|
136
|
+
params["cursor"] = cursor
|
|
137
|
+
return params
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _addr_paginated_params(
|
|
141
|
+
address: str,
|
|
142
|
+
contract_hash: str | None,
|
|
143
|
+
since_height: int | None,
|
|
144
|
+
limit: int | None,
|
|
145
|
+
cursor: str | None,
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Shape shared by ``list_settlements`` and ``get_address_history``."""
|
|
148
|
+
params: dict[str, Any] = {"address": address}
|
|
149
|
+
if contract_hash is not None:
|
|
150
|
+
params["contract_hash"] = contract_hash
|
|
151
|
+
if since_height is not None:
|
|
152
|
+
params["since_height"] = since_height
|
|
153
|
+
if limit is not None:
|
|
154
|
+
params["limit"] = limit
|
|
155
|
+
if cursor is not None:
|
|
156
|
+
params["cursor"] = cursor
|
|
157
|
+
return params
|
exfer/_transport.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""HTTP transport and JSON-RPC envelope handling.
|
|
2
|
+
|
|
3
|
+
This module is *internal* — the public surface is :class:`Client` and
|
|
4
|
+
:class:`AsyncClient`. We split the envelope build / response decode here
|
|
5
|
+
so the sync and async clients share exactly the same wire logic and
|
|
6
|
+
neither can drift from the other.
|
|
7
|
+
|
|
8
|
+
The fingerprint-pinning transports (:class:`_FingerprintHTTPTransport`,
|
|
9
|
+
:class:`_FingerprintAsyncHTTPTransport`) wrap httpx's default transports
|
|
10
|
+
and verify that the TLS leaf cert SHA-256 matches what the caller pinned
|
|
11
|
+
— this is how the SDK trusts walletd's self-signed cert without involving
|
|
12
|
+
the CA chain at all.
|
|
13
|
+
|
|
14
|
+
There are intentionally no retries here. ``exfer-walletd`` already retries
|
|
15
|
+
its own upstream node calls with linear backoff (see ``RetryPolicy`` in
|
|
16
|
+
``exfer-walletd/src/upstream/mod.rs``); stacking another retry on every
|
|
17
|
+
HTTP call would multiply latency for no benefit. Callers can wrap a single
|
|
18
|
+
method in their own retry loop if walletd itself is flaky.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import hmac
|
|
25
|
+
import itertools
|
|
26
|
+
import re
|
|
27
|
+
from collections.abc import Mapping
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
from .errors import (
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
FingerprintMismatchError,
|
|
35
|
+
ParseError,
|
|
36
|
+
ProtocolError,
|
|
37
|
+
TransportError,
|
|
38
|
+
error_for_code,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
JsonObject = dict[str, Any]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_envelope(method: str, params: Mapping[str, Any] | None, request_id: int) -> JsonObject:
|
|
45
|
+
"""Construct a JSON-RPC 2.0 request envelope.
|
|
46
|
+
|
|
47
|
+
Walletd treats missing ``params`` as ``{}``, but we always send an
|
|
48
|
+
object so wire dumps are unambiguous.
|
|
49
|
+
"""
|
|
50
|
+
return {
|
|
51
|
+
"jsonrpc": "2.0",
|
|
52
|
+
"method": method,
|
|
53
|
+
"params": dict(params) if params is not None else {},
|
|
54
|
+
"id": request_id,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decode_response(resp: httpx.Response) -> Any:
|
|
59
|
+
"""Decode a walletd JSON-RPC response and raise on error.
|
|
60
|
+
|
|
61
|
+
HTTP framing rules (matching ``exfer-walletd/src/error.rs::http_status``):
|
|
62
|
+
|
|
63
|
+
- HTTP 401: always an auth failure, body may or may not be valid JSON.
|
|
64
|
+
- HTTP 400: parse error from walletd's envelope decoder.
|
|
65
|
+
- HTTP 200: either a success or a JSON-RPC application error.
|
|
66
|
+
|
|
67
|
+
Anything else (5xx, 3xx redirects, etc.) is a :class:`TransportError`
|
|
68
|
+
because it indicates walletd itself is misbehaving or a proxy
|
|
69
|
+
intervened — not a normal RPC outcome.
|
|
70
|
+
"""
|
|
71
|
+
status = resp.status_code
|
|
72
|
+
|
|
73
|
+
# Auth failures: walletd returns 401 + a JSON body. If a proxy strips
|
|
74
|
+
# the body or returns plaintext, still raise AuthenticationError so
|
|
75
|
+
# callers don't see a confusing TransportError on a clear-cut 401.
|
|
76
|
+
if status == 401:
|
|
77
|
+
message = _safe_message(resp, default="authentication required")
|
|
78
|
+
raise AuthenticationError(message, code=-32001)
|
|
79
|
+
|
|
80
|
+
# Parse errors: walletd returns 400 + a JSON-RPC error body.
|
|
81
|
+
if status == 400:
|
|
82
|
+
body = _parse_json_or_raise(resp)
|
|
83
|
+
err = body.get("error") if isinstance(body, dict) else None
|
|
84
|
+
if isinstance(err, dict):
|
|
85
|
+
raise error_for_code(
|
|
86
|
+
int(err.get("code", -32700)),
|
|
87
|
+
str(err.get("message", "parse error")),
|
|
88
|
+
err.get("data"),
|
|
89
|
+
)
|
|
90
|
+
raise ParseError("parse error", code=-32700)
|
|
91
|
+
|
|
92
|
+
# Anything other than 200 at this point is transport-layer noise.
|
|
93
|
+
if status != 200:
|
|
94
|
+
raise TransportError(f"unexpected HTTP {status} from walletd: {resp.text[:200]!r}")
|
|
95
|
+
|
|
96
|
+
body = _parse_json_or_raise(resp)
|
|
97
|
+
if not isinstance(body, dict):
|
|
98
|
+
raise ProtocolError(f"walletd returned non-object body: {body!r}")
|
|
99
|
+
|
|
100
|
+
if "error" in body and body["error"] is not None:
|
|
101
|
+
err = body["error"]
|
|
102
|
+
if not isinstance(err, dict):
|
|
103
|
+
raise ProtocolError(f"walletd error envelope is not an object: {err!r}")
|
|
104
|
+
raise error_for_code(
|
|
105
|
+
int(err.get("code", 0)),
|
|
106
|
+
str(err.get("message", "")),
|
|
107
|
+
err.get("data"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if "result" not in body:
|
|
111
|
+
raise ProtocolError(f"walletd response missing both result and error: {body!r}")
|
|
112
|
+
|
|
113
|
+
return body["result"]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _parse_json_or_raise(resp: httpx.Response) -> Any:
|
|
117
|
+
"""Parse the response body as JSON or raise :class:`TransportError`.
|
|
118
|
+
|
|
119
|
+
walletd always emits JSON for the JSON-RPC endpoint; a non-JSON body
|
|
120
|
+
here means something between us and walletd has rewritten the response.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
return resp.json()
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise TransportError(
|
|
126
|
+
f"walletd returned non-JSON body (HTTP {resp.status_code}): {resp.text[:200]!r}"
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _safe_message(resp: httpx.Response, *, default: str) -> str:
|
|
131
|
+
"""Best-effort extraction of the JSON-RPC ``error.message`` from a 401.
|
|
132
|
+
|
|
133
|
+
Falls back to ``default`` if the body is missing, non-JSON, or has the
|
|
134
|
+
wrong shape — callers still get a useful exception either way.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
body = resp.json()
|
|
138
|
+
except ValueError:
|
|
139
|
+
return default
|
|
140
|
+
if isinstance(body, dict):
|
|
141
|
+
err = body.get("error")
|
|
142
|
+
if isinstance(err, dict):
|
|
143
|
+
msg = err.get("message")
|
|
144
|
+
if isinstance(msg, str) and msg:
|
|
145
|
+
return msg
|
|
146
|
+
return default
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _IdCounter:
|
|
150
|
+
"""Monotonic JSON-RPC request id generator.
|
|
151
|
+
|
|
152
|
+
Walletd doesn't care about the id beyond echoing it; we still emit a
|
|
153
|
+
counter so wire dumps and per-call logs can be correlated.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self) -> None:
|
|
157
|
+
self._it = itertools.count(1)
|
|
158
|
+
|
|
159
|
+
def next(self) -> int:
|
|
160
|
+
return next(self._it)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def wrap_httpx_error(exc: httpx.HTTPError) -> TransportError:
|
|
164
|
+
"""Convert an httpx connection / timeout error to :class:`TransportError`."""
|
|
165
|
+
return TransportError(f"walletd unreachable: {exc}")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
__all__ = [
|
|
169
|
+
"JsonObject",
|
|
170
|
+
"_FingerprintAsyncHTTPTransport",
|
|
171
|
+
"_FingerprintHTTPTransport",
|
|
172
|
+
"_IdCounter",
|
|
173
|
+
"build_envelope",
|
|
174
|
+
"decode_response",
|
|
175
|
+
"parse_fingerprint",
|
|
176
|
+
"wrap_httpx_error",
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Fingerprint pinning
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_FINGERPRINT_RE = re.compile(r"^sha256:([0-9a-fA-F]{64})$")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_fingerprint(s: str) -> bytes:
|
|
189
|
+
"""Validate and decode a `sha256:<hex>` fingerprint string.
|
|
190
|
+
|
|
191
|
+
The `sha256:` prefix is mandatory so we have room to add other hash
|
|
192
|
+
algorithms later without breaking existing config. Bare hex is
|
|
193
|
+
rejected with a clear error.
|
|
194
|
+
"""
|
|
195
|
+
if not isinstance(s, str):
|
|
196
|
+
raise ValueError(f"fingerprint must be a string, got {type(s).__name__}")
|
|
197
|
+
m = _FINGERPRINT_RE.match(s.strip())
|
|
198
|
+
if not m:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"invalid fingerprint {s!r}: expected 'sha256:<64-hex-char>' "
|
|
201
|
+
f"(walletd writes this in <datadir>/cert.fingerprint)"
|
|
202
|
+
)
|
|
203
|
+
return bytes.fromhex(m.group(1).lower())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _format_fingerprint(digest: bytes) -> str:
|
|
207
|
+
return f"sha256:{digest.hex()}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _verify_peer_fingerprint(response: httpx.Response, expected: bytes) -> None:
|
|
211
|
+
"""Extract the TLS peer cert from a response and compare its SHA-256.
|
|
212
|
+
|
|
213
|
+
Raises :class:`FingerprintMismatchError` on mismatch,
|
|
214
|
+
:class:`TransportError` if we couldn't reach a TLS stream at all
|
|
215
|
+
(e.g. the response came back over plain HTTP, meaning either walletd
|
|
216
|
+
isn't configured for TLS or something downgraded the connection).
|
|
217
|
+
"""
|
|
218
|
+
stream = response.extensions.get("network_stream")
|
|
219
|
+
if stream is None:
|
|
220
|
+
raise TransportError(
|
|
221
|
+
"fingerprint pinning is configured but the response carried no "
|
|
222
|
+
"network stream; HTTPS is required when fingerprint= is set"
|
|
223
|
+
)
|
|
224
|
+
ssl_object = stream.get_extra_info("ssl_object")
|
|
225
|
+
if ssl_object is None:
|
|
226
|
+
raise TransportError(
|
|
227
|
+
"fingerprint pinning is configured but the connection isn't TLS; use an https:// URL"
|
|
228
|
+
)
|
|
229
|
+
# binary_form is positional-only on stdlib ssl._SSLSocket — passing
|
|
230
|
+
# it as kwarg blows up at runtime with TypeError.
|
|
231
|
+
peer_der = ssl_object.getpeercert(True)
|
|
232
|
+
if not peer_der:
|
|
233
|
+
raise TransportError("TLS peer presented no certificate")
|
|
234
|
+
actual = hashlib.sha256(peer_der).digest()
|
|
235
|
+
if not hmac.compare_digest(actual, expected):
|
|
236
|
+
raise FingerprintMismatchError(
|
|
237
|
+
expected=_format_fingerprint(expected),
|
|
238
|
+
actual=_format_fingerprint(actual),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class _FingerprintHTTPTransport(httpx.HTTPTransport):
|
|
243
|
+
"""Sync transport that pins the TLS leaf cert SHA-256.
|
|
244
|
+
|
|
245
|
+
`verify=False` tells httpx to skip CA-chain validation — we trust the
|
|
246
|
+
cert by hash, not by issuer. Hostname verification is also skipped:
|
|
247
|
+
a self-signed leaf that hashes to the pinned value is by definition
|
|
248
|
+
the right peer.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def __init__(self, expected_sha256: bytes, **kw: Any) -> None:
|
|
252
|
+
super().__init__(verify=False, **kw)
|
|
253
|
+
self._expected = expected_sha256
|
|
254
|
+
|
|
255
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
256
|
+
response = super().handle_request(request)
|
|
257
|
+
_verify_peer_fingerprint(response, self._expected)
|
|
258
|
+
return response
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class _FingerprintAsyncHTTPTransport(httpx.AsyncHTTPTransport):
|
|
262
|
+
"""Async mirror of :class:`_FingerprintHTTPTransport`."""
|
|
263
|
+
|
|
264
|
+
def __init__(self, expected_sha256: bytes, **kw: Any) -> None:
|
|
265
|
+
super().__init__(verify=False, **kw)
|
|
266
|
+
self._expected = expected_sha256
|
|
267
|
+
|
|
268
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
269
|
+
response = await super().handle_async_request(request)
|
|
270
|
+
_verify_peer_fingerprint(response, self._expected)
|
|
271
|
+
return response
|
exfer/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.10.0"
|