deepayment 0.1.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.
- deepayment/__init__.py +52 -0
- deepayment/_protocol.py +502 -0
- deepayment/_request.py +92 -0
- deepayment/_rules.py +199 -0
- deepayment/_validate.py +125 -0
- deepayment/client.py +382 -0
- deepayment/errors.py +77 -0
- deepayment/types.py +239 -0
- deepayment-0.1.0.dist-info/METADATA +170 -0
- deepayment-0.1.0.dist-info/RECORD +13 -0
- deepayment-0.1.0.dist-info/WHEEL +5 -0
- deepayment-0.1.0.dist-info/licenses/LICENSE +202 -0
- deepayment-0.1.0.dist-info/top_level.txt +1 -0
deepayment/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Python SDK for the merchant open API.
|
|
2
|
+
|
|
3
|
+
Only business methods, DTOs, webhook verification and error types are public.
|
|
4
|
+
Signing, digest and body envelope internals live in _protocol / _request and
|
|
5
|
+
are not exported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .client import Client
|
|
9
|
+
from .errors import (
|
|
10
|
+
APIError,
|
|
11
|
+
ConfigError,
|
|
12
|
+
RequestError,
|
|
13
|
+
ResponseError,
|
|
14
|
+
ResponseTooLargeError,
|
|
15
|
+
SDKError,
|
|
16
|
+
TransportError,
|
|
17
|
+
WebhookError,
|
|
18
|
+
)
|
|
19
|
+
from .types import (
|
|
20
|
+
Balance,
|
|
21
|
+
CreatePaymentReq,
|
|
22
|
+
CreatePayoutReq,
|
|
23
|
+
EXTERNAL_STATUSES,
|
|
24
|
+
Failure,
|
|
25
|
+
OrderAction,
|
|
26
|
+
PaymentOrder,
|
|
27
|
+
PaymentWebhook,
|
|
28
|
+
PayoutOrder,
|
|
29
|
+
PayoutReceipt,
|
|
30
|
+
PayoutWebhook,
|
|
31
|
+
STATUS_CANCELED,
|
|
32
|
+
STATUS_EXPIRED,
|
|
33
|
+
STATUS_FAILED,
|
|
34
|
+
STATUS_PENDING,
|
|
35
|
+
STATUS_PROCESSING,
|
|
36
|
+
STATUS_SUCCEEDED,
|
|
37
|
+
USDRate,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__version__ = "0.1.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"Client",
|
|
44
|
+
"CreatePaymentReq", "CreatePayoutReq",
|
|
45
|
+
"PaymentOrder", "PayoutOrder", "PayoutReceipt", "Balance", "USDRate",
|
|
46
|
+
"PaymentWebhook", "PayoutWebhook", "OrderAction", "Failure",
|
|
47
|
+
"SDKError", "ConfigError", "RequestError", "APIError",
|
|
48
|
+
"ResponseError", "ResponseTooLargeError", "TransportError", "WebhookError",
|
|
49
|
+
"STATUS_PENDING", "STATUS_PROCESSING", "STATUS_SUCCEEDED",
|
|
50
|
+
"STATUS_FAILED", "STATUS_EXPIRED", "STATUS_CANCELED", "EXTERNAL_STATUSES",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
deepayment/_protocol.py
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Merchant request signing and platform webhook verification.
|
|
2
|
+
|
|
3
|
+
The protocol specification (signature, body encryption, webhook) is the source
|
|
4
|
+
of truth; cross-language consistency is guaranteed by the shared test vectors.
|
|
5
|
+
|
|
6
|
+
Depends only on the standard library and PyNaCl.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import nacl.exceptions
|
|
21
|
+
import nacl.public
|
|
22
|
+
import nacl.signing
|
|
23
|
+
|
|
24
|
+
from .errors import SDKError
|
|
25
|
+
|
|
26
|
+
HEADER_MERCHANT_ACCESS_KEY = "Merchant-Access-Key"
|
|
27
|
+
HEADER_WEBHOOK_EVENT_ID = "Webhook-Event-Id"
|
|
28
|
+
HEADER_CONTENT_ENCRYPTION = "Content-Encryption"
|
|
29
|
+
HEADER_CONTENT_DIGEST = "Content-Digest"
|
|
30
|
+
HEADER_IDEMPOTENCY_KEY = "Idempotency-Key"
|
|
31
|
+
HEADER_SIGNATURE_INPUT = "Signature-Input"
|
|
32
|
+
HEADER_SIGNATURE = "Signature"
|
|
33
|
+
|
|
34
|
+
SIGNATURE_LABEL_MERCHANT = "merchant"
|
|
35
|
+
SIGNATURE_LABEL_PLATFORM = "platform"
|
|
36
|
+
SIGNATURE_ALG_ED25519 = "ed25519"
|
|
37
|
+
CONTENT_ENCRYPTION = "sealedbox-v1-x25519-xsalsa20poly1305"
|
|
38
|
+
|
|
39
|
+
MAX_PLAIN_BODY_BYTES = 1 << 20
|
|
40
|
+
MAX_WIRE_BODY_BYTES = 2 << 20
|
|
41
|
+
MAX_SIGNATURE_LIFETIME = 300 # seconds
|
|
42
|
+
X25519_PUBLIC_KEY_SIZE = 32
|
|
43
|
+
X25519_PRIVATE_KEY_SIZE = 32
|
|
44
|
+
ED25519_SIGNATURE_SIZE = 64
|
|
45
|
+
ED25519_PUBLIC_KEY_SIZE = 32
|
|
46
|
+
|
|
47
|
+
# Fixed by the protocol; tuple order is the signature base order
|
|
48
|
+
MERCHANT_WRITE_COVERED = (
|
|
49
|
+
"@method",
|
|
50
|
+
"@path",
|
|
51
|
+
"content-type",
|
|
52
|
+
"content-encryption",
|
|
53
|
+
"content-digest",
|
|
54
|
+
"idempotency-key",
|
|
55
|
+
"merchant-access-key",
|
|
56
|
+
)
|
|
57
|
+
MERCHANT_READ_COVERED = (
|
|
58
|
+
"@method",
|
|
59
|
+
"@path",
|
|
60
|
+
"@query",
|
|
61
|
+
"merchant-access-key",
|
|
62
|
+
)
|
|
63
|
+
PLATFORM_COVERED = (
|
|
64
|
+
"@method",
|
|
65
|
+
"@path",
|
|
66
|
+
"@query",
|
|
67
|
+
"content-type",
|
|
68
|
+
"content-digest",
|
|
69
|
+
"webhook-event-id",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
ALLOWED_READ_QUERY_FIELDS = frozenset({
|
|
73
|
+
"orderNo", "merchantOrderNo", "currency", "payMethod", "status",
|
|
74
|
+
"startTime", "endTime", "page", "pageSize", "consentNo",
|
|
75
|
+
"subscriptionPaymentNo", "planNo", "merchantPlanNo", "merchantCustomerNo",
|
|
76
|
+
"subscriptionNo", "merchantSubscriptionNo", "invoiceNo",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
_CROCKFORD32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ProtocolError(SDKError):
|
|
83
|
+
"""Base class for protocol-level errors."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InvalidHeaderError(ProtocolError):
|
|
87
|
+
"""Signature headers, request shape or parameters are invalid."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class InvalidEnvelopeError(ProtocolError):
|
|
91
|
+
"""Body envelope is malformed or cannot be decrypted."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class InvalidSignatureError(ProtocolError):
|
|
95
|
+
"""Signature is malformed or verification failed."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ExpiredSignatureError(ProtocolError):
|
|
99
|
+
"""Signature time window is invalid or has expired."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class SignatureParams:
|
|
104
|
+
"""Signature parameters of the fixed RFC 9421 profile."""
|
|
105
|
+
|
|
106
|
+
label: str
|
|
107
|
+
covered: tuple[str, ...]
|
|
108
|
+
created: int
|
|
109
|
+
expires: int
|
|
110
|
+
nonce: str
|
|
111
|
+
alg: str = SIGNATURE_ALG_ED25519
|
|
112
|
+
key_id: str = "" # only platform webhooks carry keyid; merchant requests never do
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class BodyEnvelope:
|
|
117
|
+
"""Encrypted envelope of a merchant POST wire body."""
|
|
118
|
+
|
|
119
|
+
version: int
|
|
120
|
+
alg: str
|
|
121
|
+
key_id: str
|
|
122
|
+
ciphertext: str
|
|
123
|
+
|
|
124
|
+
def to_json_bytes(self) -> bytes:
|
|
125
|
+
return json.dumps(
|
|
126
|
+
{
|
|
127
|
+
"version": self.version,
|
|
128
|
+
"alg": self.alg,
|
|
129
|
+
"keyId": self.key_id,
|
|
130
|
+
"ciphertext": self.ciphertext,
|
|
131
|
+
},
|
|
132
|
+
separators=(",", ":"),
|
|
133
|
+
).encode()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def new_merchant_write_signature_params(nonce: str, now: int | None = None) -> SignatureParams:
|
|
137
|
+
created = int(time.time()) if now is None else int(now)
|
|
138
|
+
return SignatureParams(
|
|
139
|
+
label=SIGNATURE_LABEL_MERCHANT,
|
|
140
|
+
covered=MERCHANT_WRITE_COVERED,
|
|
141
|
+
created=created,
|
|
142
|
+
expires=created + MAX_SIGNATURE_LIFETIME,
|
|
143
|
+
nonce=nonce,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def new_merchant_read_signature_params(nonce: str, now: int | None = None) -> SignatureParams:
|
|
148
|
+
created = int(time.time()) if now is None else int(now)
|
|
149
|
+
return SignatureParams(
|
|
150
|
+
label=SIGNATURE_LABEL_MERCHANT,
|
|
151
|
+
covered=MERCHANT_READ_COVERED,
|
|
152
|
+
created=created,
|
|
153
|
+
expires=created + MAX_SIGNATURE_LIFETIME,
|
|
154
|
+
nonce=nonce,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def new_platform_signature_params(key_id: str, nonce: str, now: int | None = None) -> SignatureParams:
|
|
159
|
+
created = int(time.time()) if now is None else int(now)
|
|
160
|
+
return SignatureParams(
|
|
161
|
+
label=SIGNATURE_LABEL_PLATFORM,
|
|
162
|
+
covered=PLATFORM_COVERED,
|
|
163
|
+
created=created,
|
|
164
|
+
expires=created + MAX_SIGNATURE_LIFETIME,
|
|
165
|
+
nonce=nonce,
|
|
166
|
+
key_id=key_id,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def new_nonce() -> str:
|
|
171
|
+
return str(uuid.UUID(bytes=secrets.token_bytes(16), version=4))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _quote(value: str) -> str:
|
|
175
|
+
"""Protocol values are ASCII without escapes, so JSON quoting yields the RFC 9421 form."""
|
|
176
|
+
return json.dumps(value, ensure_ascii=False)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def signature_input_value(params: SignatureParams) -> str:
|
|
180
|
+
"""The Signature-Input value after the label; also the @signature-params line of the base."""
|
|
181
|
+
components = " ".join(_quote(c) for c in params.covered)
|
|
182
|
+
value = (
|
|
183
|
+
f"({components})"
|
|
184
|
+
f";created={params.created}"
|
|
185
|
+
f";expires={params.expires}"
|
|
186
|
+
f";nonce={_quote(params.nonce)}"
|
|
187
|
+
)
|
|
188
|
+
if params.label == SIGNATURE_LABEL_PLATFORM:
|
|
189
|
+
value += f";keyid={_quote(params.key_id)}"
|
|
190
|
+
return value + f";alg={_quote(params.alg)}"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def signature_input_header(params: SignatureParams) -> str:
|
|
194
|
+
validate_signature_params(params, params.created)
|
|
195
|
+
return f"{params.label}={signature_input_value(params)}"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _component_value(
|
|
199
|
+
component: str, *, method: str, path: str, raw_query: str, headers: dict[str, str]
|
|
200
|
+
) -> str:
|
|
201
|
+
if component == "@method":
|
|
202
|
+
return method
|
|
203
|
+
if component == "@path":
|
|
204
|
+
return path or "/"
|
|
205
|
+
if component == "@query":
|
|
206
|
+
return "?" + raw_query
|
|
207
|
+
name = {
|
|
208
|
+
"content-type": "Content-Type",
|
|
209
|
+
"content-encryption": HEADER_CONTENT_ENCRYPTION,
|
|
210
|
+
"content-digest": HEADER_CONTENT_DIGEST,
|
|
211
|
+
"idempotency-key": HEADER_IDEMPOTENCY_KEY,
|
|
212
|
+
"merchant-access-key": HEADER_MERCHANT_ACCESS_KEY,
|
|
213
|
+
"webhook-event-id": HEADER_WEBHOOK_EVENT_ID,
|
|
214
|
+
}.get(component)
|
|
215
|
+
if name is None:
|
|
216
|
+
raise InvalidHeaderError(f"unknown covered component: {component}")
|
|
217
|
+
value = headers.get(name)
|
|
218
|
+
if value is None:
|
|
219
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
220
|
+
value = lowered.get(name.lower())
|
|
221
|
+
if value is None or not _valid_header_value(value.strip()):
|
|
222
|
+
raise InvalidHeaderError(f"missing or invalid header: {name}")
|
|
223
|
+
return value.strip()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def signature_base(
|
|
227
|
+
params: SignatureParams,
|
|
228
|
+
*,
|
|
229
|
+
method: str,
|
|
230
|
+
path: str,
|
|
231
|
+
raw_query: str = "",
|
|
232
|
+
headers: dict[str, str] | None = None,
|
|
233
|
+
) -> bytes:
|
|
234
|
+
"""Build the RFC 9421 signature base."""
|
|
235
|
+
if not params.covered:
|
|
236
|
+
raise InvalidHeaderError("empty covered components")
|
|
237
|
+
headers = headers or {}
|
|
238
|
+
lines = [
|
|
239
|
+
f"{_quote(c)}: "
|
|
240
|
+
+ _component_value(c, method=method, path=path, raw_query=raw_query, headers=headers)
|
|
241
|
+
for c in params.covered
|
|
242
|
+
]
|
|
243
|
+
lines.append(f'{_quote("@signature-params")}: {signature_input_value(params)}')
|
|
244
|
+
return "\n".join(lines).encode()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _valid_header_value(value: str) -> bool:
|
|
248
|
+
return bool(value) and value.strip() == value and "\r" not in value and "\n" not in value
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def valid_uuid_v4(value: str) -> bool:
|
|
252
|
+
"""Lowercase UUID v4; uppercase hex digits are rejected."""
|
|
253
|
+
if len(value) != 36:
|
|
254
|
+
return False
|
|
255
|
+
for i, ch in enumerate(value):
|
|
256
|
+
if i in (8, 13, 18, 23):
|
|
257
|
+
if ch != "-":
|
|
258
|
+
return False
|
|
259
|
+
elif i == 14:
|
|
260
|
+
if ch != "4":
|
|
261
|
+
return False
|
|
262
|
+
elif i == 19:
|
|
263
|
+
if ch not in "89ab":
|
|
264
|
+
return False
|
|
265
|
+
elif not ("0" <= ch <= "9" or "a" <= ch <= "f"):
|
|
266
|
+
return False
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def validate_idempotency_key(value: str) -> None:
|
|
271
|
+
if not valid_uuid_v4(value):
|
|
272
|
+
raise InvalidHeaderError("Idempotency-Key must be a lowercase UUID v4")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def validate_webhook_event_id(value: str) -> None:
|
|
276
|
+
if not value.startswith("evt_") or len(value) != 30:
|
|
277
|
+
raise InvalidHeaderError("invalid Webhook-Event-Id")
|
|
278
|
+
if any(ch not in _CROCKFORD32 for ch in value[4:]):
|
|
279
|
+
raise InvalidHeaderError("invalid Webhook-Event-Id")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def validate_merchant_read_query(raw_query: str) -> None:
|
|
283
|
+
"""A signed GET query may carry only allowed locator fields, one value per key."""
|
|
284
|
+
if not raw_query:
|
|
285
|
+
return
|
|
286
|
+
if re.search(r"%(?![0-9A-Fa-f]{2})", raw_query):
|
|
287
|
+
raise InvalidHeaderError("query contains malformed percent-encoding")
|
|
288
|
+
from urllib.parse import parse_qs
|
|
289
|
+
|
|
290
|
+
values = parse_qs(raw_query, keep_blank_values=True, strict_parsing=False)
|
|
291
|
+
for key, items in values.items():
|
|
292
|
+
if key not in ALLOWED_READ_QUERY_FIELDS or len(items) != 1 or not items[0].strip():
|
|
293
|
+
raise InvalidHeaderError(f"query field not allowed: {key}")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def validate_freshness(params: SignatureParams, now: int) -> None:
|
|
297
|
+
if (
|
|
298
|
+
params.created <= 0
|
|
299
|
+
or params.expires <= 0
|
|
300
|
+
or params.expires <= params.created
|
|
301
|
+
or params.expires - params.created > MAX_SIGNATURE_LIFETIME
|
|
302
|
+
):
|
|
303
|
+
raise ExpiredSignatureError("invalid signature lifetime")
|
|
304
|
+
if now < params.created - MAX_SIGNATURE_LIFETIME or now > params.expires:
|
|
305
|
+
raise ExpiredSignatureError("signature expired")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def validate_signature_params(params: SignatureParams, now: int) -> None:
|
|
309
|
+
if not params.label or params.alg != SIGNATURE_ALG_ED25519 or not valid_uuid_v4(params.nonce):
|
|
310
|
+
raise InvalidHeaderError("invalid signature params")
|
|
311
|
+
if params.label == SIGNATURE_LABEL_PLATFORM:
|
|
312
|
+
if not _valid_header_value(params.key_id):
|
|
313
|
+
raise InvalidHeaderError("platform signature requires keyid")
|
|
314
|
+
elif params.label != SIGNATURE_LABEL_MERCHANT:
|
|
315
|
+
raise InvalidHeaderError("unknown signature label")
|
|
316
|
+
if not _covered_components_allowed(params.label, params.covered):
|
|
317
|
+
raise InvalidHeaderError("covered components not allowed")
|
|
318
|
+
validate_freshness(params, now)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _covered_components_allowed(label: str, covered: tuple[str, ...]) -> bool:
|
|
322
|
+
if label == SIGNATURE_LABEL_MERCHANT:
|
|
323
|
+
return covered in (MERCHANT_WRITE_COVERED, MERCHANT_READ_COVERED)
|
|
324
|
+
if label == SIGNATURE_LABEL_PLATFORM:
|
|
325
|
+
return covered == PLATFORM_COVERED
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def parse_signature_input(value: str, label: str, covered: tuple[str, ...]) -> SignatureParams:
|
|
330
|
+
prefix = f"{label}=("
|
|
331
|
+
if not value.startswith(prefix):
|
|
332
|
+
raise InvalidHeaderError("bad Signature-Input label")
|
|
333
|
+
closing = value.find(")")
|
|
334
|
+
if closing < 0 or closing + 1 >= len(value) or value[closing + 1] != ";":
|
|
335
|
+
raise InvalidHeaderError("bad Signature-Input components")
|
|
336
|
+
raw_components = value[len(prefix):closing].split()
|
|
337
|
+
if len(raw_components) != len(covered):
|
|
338
|
+
raise InvalidHeaderError("covered components mismatch")
|
|
339
|
+
for raw, want in zip(raw_components, covered):
|
|
340
|
+
if json.loads(raw) != want:
|
|
341
|
+
raise InvalidHeaderError("covered components mismatch")
|
|
342
|
+
|
|
343
|
+
parts = value[closing + 2:].split(";")
|
|
344
|
+
expected = 5 if label == SIGNATURE_LABEL_PLATFORM else 4
|
|
345
|
+
if len(parts) != expected:
|
|
346
|
+
raise InvalidHeaderError("bad Signature-Input params")
|
|
347
|
+
got: dict[str, str] = {}
|
|
348
|
+
for part in parts:
|
|
349
|
+
key, sep, raw = part.partition("=")
|
|
350
|
+
if not sep or not key or key in got:
|
|
351
|
+
raise InvalidHeaderError("bad Signature-Input params")
|
|
352
|
+
got[key] = raw
|
|
353
|
+
if got.keys() - {"created", "expires", "nonce", "keyid", "alg"}:
|
|
354
|
+
raise InvalidHeaderError("unknown Signature-Input param")
|
|
355
|
+
if "keyid" in got and label != SIGNATURE_LABEL_PLATFORM:
|
|
356
|
+
raise InvalidHeaderError("merchant signature must not carry keyid")
|
|
357
|
+
try:
|
|
358
|
+
return SignatureParams(
|
|
359
|
+
label=label,
|
|
360
|
+
covered=covered,
|
|
361
|
+
created=int(got["created"]),
|
|
362
|
+
expires=int(got["expires"]),
|
|
363
|
+
nonce=json.loads(got["nonce"]),
|
|
364
|
+
alg=json.loads(got["alg"]),
|
|
365
|
+
key_id=json.loads(got["keyid"]) if "keyid" in got else "",
|
|
366
|
+
)
|
|
367
|
+
except (KeyError, ValueError) as exc:
|
|
368
|
+
raise InvalidHeaderError("bad Signature-Input params") from exc
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def parse_merchant_write_signature_input(value: str) -> SignatureParams:
|
|
372
|
+
return parse_signature_input(value, SIGNATURE_LABEL_MERCHANT, MERCHANT_WRITE_COVERED)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def parse_merchant_read_signature_input(value: str) -> SignatureParams:
|
|
376
|
+
return parse_signature_input(value, SIGNATURE_LABEL_MERCHANT, MERCHANT_READ_COVERED)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def parse_platform_signature_input(value: str) -> SignatureParams:
|
|
380
|
+
return parse_signature_input(value, SIGNATURE_LABEL_PLATFORM, PLATFORM_COVERED)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def content_digest_sha256(body: bytes) -> str:
|
|
384
|
+
"""RFC 9530 Content-Digest value with sha-256."""
|
|
385
|
+
return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def verify_content_digest(body: bytes, header: str) -> bool:
|
|
389
|
+
return secrets.compare_digest(header, content_digest_sha256(body))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _signing_key(private_key: bytes) -> nacl.signing.SigningKey:
|
|
393
|
+
# 64-byte keys are seed||pub; PyNaCl takes only the 32-byte seed
|
|
394
|
+
if len(private_key) == 64:
|
|
395
|
+
return nacl.signing.SigningKey(private_key[:32])
|
|
396
|
+
if len(private_key) == 32:
|
|
397
|
+
return nacl.signing.SigningKey(private_key)
|
|
398
|
+
raise InvalidSignatureError("ed25519 private key must be 32 or 64 bytes")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def sign_ed25519(private_key: bytes, label: str, base: bytes) -> str:
|
|
402
|
+
if not label or not base:
|
|
403
|
+
raise InvalidSignatureError("empty label or signature base")
|
|
404
|
+
signature = _signing_key(private_key).sign(base).signature
|
|
405
|
+
return signature_header(label, signature)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def verify_ed25519(public_key: bytes, base: bytes, signature: bytes) -> None:
|
|
409
|
+
if (
|
|
410
|
+
len(public_key) != ED25519_PUBLIC_KEY_SIZE
|
|
411
|
+
or not base
|
|
412
|
+
or len(signature) != ED25519_SIGNATURE_SIZE
|
|
413
|
+
):
|
|
414
|
+
raise InvalidSignatureError("invalid ed25519 verify input")
|
|
415
|
+
try:
|
|
416
|
+
nacl.signing.VerifyKey(public_key).verify(base, signature)
|
|
417
|
+
except nacl.exceptions.BadSignatureError as exc:
|
|
418
|
+
raise InvalidSignatureError("signature verification failed") from exc
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def signature_header(label: str, signature: bytes) -> str:
|
|
422
|
+
if not _valid_header_value(label) or len(signature) != ED25519_SIGNATURE_SIZE:
|
|
423
|
+
raise InvalidSignatureError("invalid signature header input")
|
|
424
|
+
return f"{label}=:" + base64.b64encode(signature).decode() + ":"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def parse_signature(value: str, label: str) -> bytes:
|
|
428
|
+
if not _valid_header_value(label):
|
|
429
|
+
raise InvalidSignatureError("invalid signature label")
|
|
430
|
+
prefix = f"{label}=:"
|
|
431
|
+
if not value.startswith(prefix) or not value.endswith(":"):
|
|
432
|
+
raise InvalidSignatureError("malformed Signature header")
|
|
433
|
+
try:
|
|
434
|
+
signature = base64.b64decode(value[len(prefix):-1], validate=True)
|
|
435
|
+
except (ValueError, base64.binascii.Error) as exc:
|
|
436
|
+
raise InvalidSignatureError("malformed Signature header") from exc
|
|
437
|
+
if len(signature) != ED25519_SIGNATURE_SIZE:
|
|
438
|
+
raise InvalidSignatureError("malformed Signature header")
|
|
439
|
+
return signature
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def seal_body_envelope(plaintext: bytes, public_key: bytes, key_id: str) -> bytes:
|
|
443
|
+
"""Seal a POST plaintext body to the platform X25519 public key; returns the wire body."""
|
|
444
|
+
if not plaintext or len(plaintext) > MAX_PLAIN_BODY_BYTES:
|
|
445
|
+
raise InvalidEnvelopeError("invalid plaintext size")
|
|
446
|
+
if len(public_key) != X25519_PUBLIC_KEY_SIZE or not _valid_header_value(key_id):
|
|
447
|
+
raise InvalidEnvelopeError("invalid platform body key")
|
|
448
|
+
ciphertext = nacl.public.SealedBox(nacl.public.PublicKey(public_key)).encrypt(plaintext)
|
|
449
|
+
return BodyEnvelope(
|
|
450
|
+
version=1,
|
|
451
|
+
alg=CONTENT_ENCRYPTION,
|
|
452
|
+
key_id=key_id,
|
|
453
|
+
ciphertext=base64.b64encode(ciphertext).decode(),
|
|
454
|
+
).to_json_bytes()
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def decode_body_envelope(wire_body: bytes) -> BodyEnvelope:
|
|
458
|
+
if not wire_body or len(wire_body) > MAX_WIRE_BODY_BYTES:
|
|
459
|
+
raise InvalidEnvelopeError("invalid envelope size")
|
|
460
|
+
try:
|
|
461
|
+
raw = json.loads(wire_body)
|
|
462
|
+
except ValueError as exc:
|
|
463
|
+
raise InvalidEnvelopeError("envelope is not valid json") from exc
|
|
464
|
+
if not isinstance(raw, dict) or raw.keys() != {"version", "alg", "keyId", "ciphertext"}:
|
|
465
|
+
raise InvalidEnvelopeError("unexpected envelope fields")
|
|
466
|
+
envelope = BodyEnvelope(
|
|
467
|
+
version=raw["version"], alg=raw["alg"], key_id=raw["keyId"], ciphertext=raw["ciphertext"]
|
|
468
|
+
)
|
|
469
|
+
if (
|
|
470
|
+
envelope.version != 1
|
|
471
|
+
or envelope.alg != CONTENT_ENCRYPTION
|
|
472
|
+
or not _valid_header_value(str(envelope.key_id))
|
|
473
|
+
or not envelope.ciphertext
|
|
474
|
+
):
|
|
475
|
+
raise InvalidEnvelopeError("invalid envelope")
|
|
476
|
+
try:
|
|
477
|
+
base64.b64decode(envelope.ciphertext, validate=True)
|
|
478
|
+
except (ValueError, base64.binascii.Error) as exc:
|
|
479
|
+
raise InvalidEnvelopeError("invalid envelope ciphertext") from exc
|
|
480
|
+
return envelope
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def peek_body_envelope_key_id(wire_body: bytes) -> str:
|
|
484
|
+
try:
|
|
485
|
+
return decode_body_envelope(wire_body).key_id
|
|
486
|
+
except InvalidEnvelopeError:
|
|
487
|
+
return ""
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def open_body_envelope(wire_body: bytes, public_key: bytes, private_key: bytes) -> tuple[bytes, str]:
|
|
491
|
+
"""Open an envelope with the platform X25519 key pair; returns (plaintext, keyId)."""
|
|
492
|
+
if len(public_key) != X25519_PUBLIC_KEY_SIZE or len(private_key) != X25519_PRIVATE_KEY_SIZE:
|
|
493
|
+
raise InvalidEnvelopeError("invalid platform body key pair")
|
|
494
|
+
envelope = decode_body_envelope(wire_body)
|
|
495
|
+
ciphertext = base64.b64decode(envelope.ciphertext, validate=True)
|
|
496
|
+
try:
|
|
497
|
+
plaintext = nacl.public.SealedBox(nacl.public.PrivateKey(private_key)).decrypt(ciphertext)
|
|
498
|
+
except nacl.exceptions.CryptoError as exc:
|
|
499
|
+
raise InvalidEnvelopeError("envelope decrypt failed") from exc
|
|
500
|
+
if not plaintext or len(plaintext) > MAX_PLAIN_BODY_BYTES:
|
|
501
|
+
raise InvalidEnvelopeError("invalid plaintext size")
|
|
502
|
+
return plaintext, envelope.key_id
|
deepayment/_request.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Signed request construction.
|
|
2
|
+
|
|
3
|
+
build_write / build_read only produce headers and the wire body and never send
|
|
4
|
+
anything, so they can be tested directly against the protocol vectors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from . import _protocol as p
|
|
13
|
+
from .errors import RequestError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class BuiltRequest:
|
|
18
|
+
method: str
|
|
19
|
+
headers: dict[str, str]
|
|
20
|
+
body: bytes = b""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_write(
|
|
24
|
+
*,
|
|
25
|
+
endpoint_url: str,
|
|
26
|
+
access_key: str,
|
|
27
|
+
idempotency_key: str,
|
|
28
|
+
body_key_id: str,
|
|
29
|
+
body_public_key: bytes,
|
|
30
|
+
merchant_private_key: bytes,
|
|
31
|
+
body: bytes,
|
|
32
|
+
nonce: str,
|
|
33
|
+
now: int | None = None,
|
|
34
|
+
) -> BuiltRequest:
|
|
35
|
+
"""Signed POST; the digest covers the sealed body, not the plaintext."""
|
|
36
|
+
if not (endpoint_url and access_key and idempotency_key and body_key_id and body):
|
|
37
|
+
raise RequestError("sdk: invalid signed request")
|
|
38
|
+
p.validate_idempotency_key(idempotency_key)
|
|
39
|
+
|
|
40
|
+
parts = urlsplit(endpoint_url)
|
|
41
|
+
if parts.query:
|
|
42
|
+
raise RequestError("sdk: signed POST query is not allowed")
|
|
43
|
+
|
|
44
|
+
wire_body = p.seal_body_envelope(body, body_public_key, body_key_id)
|
|
45
|
+
params = p.new_merchant_write_signature_params(nonce, now)
|
|
46
|
+
|
|
47
|
+
headers = {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
p.HEADER_CONTENT_ENCRYPTION: p.CONTENT_ENCRYPTION,
|
|
50
|
+
p.HEADER_CONTENT_DIGEST: p.content_digest_sha256(wire_body),
|
|
51
|
+
p.HEADER_IDEMPOTENCY_KEY: idempotency_key,
|
|
52
|
+
p.HEADER_MERCHANT_ACCESS_KEY: access_key,
|
|
53
|
+
}
|
|
54
|
+
headers[p.HEADER_SIGNATURE_INPUT] = p.signature_input_header(params)
|
|
55
|
+
base = p.signature_base(
|
|
56
|
+
params, method="POST", path=parts.path or "/", raw_query="", headers=headers
|
|
57
|
+
)
|
|
58
|
+
headers[p.HEADER_SIGNATURE] = p.sign_ed25519(
|
|
59
|
+
merchant_private_key, p.SIGNATURE_LABEL_MERCHANT, base
|
|
60
|
+
)
|
|
61
|
+
return BuiltRequest(method="POST", headers=headers, body=wire_body)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_read(
|
|
65
|
+
*,
|
|
66
|
+
endpoint_url: str,
|
|
67
|
+
access_key: str,
|
|
68
|
+
merchant_private_key: bytes,
|
|
69
|
+
nonce: str,
|
|
70
|
+
now: int | None = None,
|
|
71
|
+
) -> BuiltRequest:
|
|
72
|
+
"""Signed GET: no body and no idempotency key; the signature covers method, path, query and access key."""
|
|
73
|
+
if not endpoint_url or not access_key:
|
|
74
|
+
raise RequestError("sdk: invalid signed request")
|
|
75
|
+
|
|
76
|
+
parts = urlsplit(endpoint_url)
|
|
77
|
+
p.validate_merchant_read_query(parts.query)
|
|
78
|
+
params = p.new_merchant_read_signature_params(nonce, now)
|
|
79
|
+
|
|
80
|
+
headers = {p.HEADER_MERCHANT_ACCESS_KEY: access_key}
|
|
81
|
+
headers[p.HEADER_SIGNATURE_INPUT] = p.signature_input_header(params)
|
|
82
|
+
base = p.signature_base(
|
|
83
|
+
params,
|
|
84
|
+
method="GET",
|
|
85
|
+
path=parts.path or "/",
|
|
86
|
+
raw_query=parts.query,
|
|
87
|
+
headers=headers,
|
|
88
|
+
)
|
|
89
|
+
headers[p.HEADER_SIGNATURE] = p.sign_ed25519(
|
|
90
|
+
merchant_private_key, p.SIGNATURE_LABEL_MERCHANT, base
|
|
91
|
+
)
|
|
92
|
+
return BuiltRequest(method="GET", headers=headers)
|